feat: Add dictionary word lookup feature with cached index
Implements StarDict-based dictionary lookup from the reader menu, adapted from upstream PR #857 with /.dictionary/ folder path, std::vector compatibility (PR #802), HTML definition rendering, orientation-aware button hints, side button hints with CCW text rotation, sparse index caching to SD card, pronunciation line filtering, and reorganized reader menu with bookmark stubs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
537
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
537
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
@@ -0,0 +1,537 @@
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void DictionaryDefinitionActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<DictionaryDefinitionActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
wrapText();
|
||||
updateRequired = true;
|
||||
xTaskCreate(&DictionaryDefinitionActivity::taskTrampoline, "DictDefTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::onExit() {
|
||||
Activity::onExit();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
|
||||
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
|
||||
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
|
||||
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
|
||||
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
|
||||
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
|
||||
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
|
||||
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
|
||||
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML entity decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
|
||||
// Named entities
|
||||
if (entity == "amp") return "&";
|
||||
if (entity == "lt") return "<";
|
||||
if (entity == "gt") return ">";
|
||||
if (entity == "quot") return "\"";
|
||||
if (entity == "apos") return "'";
|
||||
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
|
||||
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
|
||||
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
|
||||
if (entity == "lsquo") return "\xE2\x80\x98";
|
||||
if (entity == "rsquo") return "\xE2\x80\x99";
|
||||
if (entity == "ldquo") return "\xE2\x80\x9C";
|
||||
if (entity == "rdquo") return "\xE2\x80\x9D";
|
||||
if (entity == "hellip") return "\xE2\x80\xA6";
|
||||
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
|
||||
|
||||
// Numeric entities: { or 
|
||||
if (!entity.empty() && entity[0] == '#') {
|
||||
unsigned long cp = 0;
|
||||
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
|
||||
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
|
||||
} else {
|
||||
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
|
||||
}
|
||||
if (cp > 0 && cp < 0x80) {
|
||||
return std::string(1, static_cast<char>(cp));
|
||||
}
|
||||
if (cp >= 0x80 && cp < 0x800) {
|
||||
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 2);
|
||||
}
|
||||
if (cp >= 0x800 && cp < 0x10000) {
|
||||
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
|
||||
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 3);
|
||||
}
|
||||
if (cp >= 0x10000 && cp < 0x110000) {
|
||||
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
|
||||
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return ""; // unknown entity — drop it
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML → TextAtom list
|
||||
// ---------------------------------------------------------------------------
|
||||
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
|
||||
std::vector<TextAtom> atoms;
|
||||
|
||||
bool isBold = false;
|
||||
bool isItalic = false;
|
||||
bool inSvg = false;
|
||||
int svgDepth = 0;
|
||||
std::vector<ListState> listStack;
|
||||
std::string currentWord;
|
||||
|
||||
auto currentStyle = [&]() -> EpdFontFamily::Style {
|
||||
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
|
||||
if (isBold) return EpdFontFamily::BOLD;
|
||||
if (isItalic) return EpdFontFamily::ITALIC;
|
||||
return EpdFontFamily::REGULAR;
|
||||
};
|
||||
|
||||
auto flushWord = [&]() {
|
||||
if (!currentWord.empty() && !inSvg) {
|
||||
atoms.push_back({currentWord, currentStyle(), false, 0});
|
||||
currentWord.clear();
|
||||
}
|
||||
};
|
||||
|
||||
auto indentPx = [&]() -> int {
|
||||
// 15 pixels per nesting level (the first level has no extra indent)
|
||||
int depth = static_cast<int>(listStack.size());
|
||||
return (depth > 1) ? (depth - 1) * 15 : 0;
|
||||
};
|
||||
|
||||
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
|
||||
// that appears before the first tag in sametypesequence=h entries.
|
||||
size_t i = 0;
|
||||
{
|
||||
size_t firstTag = html.find('<');
|
||||
if (firstTag != std::string::npos) i = firstTag;
|
||||
}
|
||||
|
||||
while (i < html.size()) {
|
||||
// ------- HTML tag -------
|
||||
if (html[i] == '<') {
|
||||
flushWord();
|
||||
|
||||
size_t tagEnd = html.find('>', i);
|
||||
if (tagEnd == std::string::npos) break;
|
||||
|
||||
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
|
||||
|
||||
// Extract tag name: first token, lowercased, trailing '/' stripped.
|
||||
size_t space = tagContent.find(' ');
|
||||
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
|
||||
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
|
||||
|
||||
// --- SVG handling (skip all content inside <svg>…</svg>) ---
|
||||
if (tagName == "svg") {
|
||||
inSvg = true;
|
||||
svgDepth = 1;
|
||||
} else if (inSvg) {
|
||||
if (tagName == "svg") {
|
||||
svgDepth++;
|
||||
} else if (tagName == "/svg") {
|
||||
svgDepth--;
|
||||
if (svgDepth <= 0) inSvg = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inSvg) {
|
||||
// --- Inline style tags ---
|
||||
if (tagName == "b" || tagName == "strong") {
|
||||
isBold = true;
|
||||
} else if (tagName == "/b" || tagName == "/strong") {
|
||||
isBold = false;
|
||||
} else if (tagName == "i" || tagName == "em") {
|
||||
isItalic = true;
|
||||
} else if (tagName == "/i" || tagName == "/em") {
|
||||
isItalic = false;
|
||||
|
||||
// --- Block-level tags → newlines ---
|
||||
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
// Headings get bold style applied to following text
|
||||
if (tagName != "p") isBold = true;
|
||||
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
isBold = false;
|
||||
} else if (tagName == "br") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
|
||||
// --- Separator between definition entries ---
|
||||
} else if (tagName == "/html") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
// Skip any raw text between </html> and the next tag — this is where
|
||||
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
|
||||
size_t nextTag = html.find('<', tagEnd + 1);
|
||||
i = (nextTag != std::string::npos) ? nextTag : html.size();
|
||||
continue;
|
||||
|
||||
// --- Lists ---
|
||||
} else if (tagName == "ol") {
|
||||
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
|
||||
listStack.push_back({0, alpha});
|
||||
} else if (tagName == "ul") {
|
||||
listStack.push_back({0, false});
|
||||
} else if (tagName == "/ol" || tagName == "/ul") {
|
||||
if (!listStack.empty()) listStack.pop_back();
|
||||
} else if (tagName == "li") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
if (!listStack.empty()) {
|
||||
auto& ls = listStack.back();
|
||||
ls.counter++;
|
||||
std::string marker;
|
||||
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
|
||||
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
|
||||
} else if (ls.isAlpha) {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
} else {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
}
|
||||
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
|
||||
} else {
|
||||
// Unordered list or bare <li>
|
||||
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
|
||||
}
|
||||
}
|
||||
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
|
||||
// their text content will still be emitted.
|
||||
}
|
||||
|
||||
i = tagEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip content inside SVG
|
||||
if (inSvg) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- HTML entity -------
|
||||
if (html[i] == '&') {
|
||||
size_t semicolon = html.find(';', i);
|
||||
if (semicolon != std::string::npos && semicolon - i < 16) {
|
||||
std::string entity = html.substr(i + 1, semicolon - i - 1);
|
||||
std::string decoded = decodeEntity(entity);
|
||||
if (!decoded.empty()) {
|
||||
// Treat decoded chars like normal text (could be space etc.)
|
||||
for (char dc : decoded) {
|
||||
if (dc == ' ') {
|
||||
flushWord();
|
||||
} else {
|
||||
currentWord += dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
// Not a valid entity — emit '&' literally
|
||||
currentWord += '&';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
|
||||
if (html[i] == '/' || html[i] == '[') {
|
||||
char closeDelim = (html[i] == '/') ? '/' : ']';
|
||||
size_t end = html.find(closeDelim, i + 1);
|
||||
if (end != std::string::npos && end - i < 80) {
|
||||
bool hasNonAscii = false;
|
||||
for (size_t j = i + 1; j < end; j++) {
|
||||
if (static_cast<unsigned char>(html[j]) > 127) {
|
||||
hasNonAscii = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonAscii) {
|
||||
flushWord();
|
||||
i = end + 1; // skip entire IPA section including delimiters
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not IPA — fall through to treat as regular character
|
||||
}
|
||||
|
||||
// ------- Whitespace -------
|
||||
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
|
||||
flushWord();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- Regular character (with non-renderable character filter) -------
|
||||
{
|
||||
unsigned char byte = static_cast<unsigned char>(html[i]);
|
||||
if (byte < 0x80) {
|
||||
// ASCII — always renderable
|
||||
currentWord += html[i];
|
||||
i++;
|
||||
} else {
|
||||
// Multi-byte UTF-8: decode codepoint and check if renderable
|
||||
int seqLen = 1;
|
||||
uint32_t cp = 0;
|
||||
if ((byte & 0xE0) == 0xC0) {
|
||||
seqLen = 2;
|
||||
cp = byte & 0x1F;
|
||||
} else if ((byte & 0xF0) == 0xE0) {
|
||||
seqLen = 3;
|
||||
cp = byte & 0x0F;
|
||||
} else if ((byte & 0xF8) == 0xF0) {
|
||||
seqLen = 4;
|
||||
cp = byte & 0x07;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
} // invalid start byte
|
||||
|
||||
if (i + static_cast<size_t>(seqLen) > html.size()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool valid = true;
|
||||
for (int j = 1; j < seqLen; j++) {
|
||||
unsigned char cb = static_cast<unsigned char>(html[i + j]);
|
||||
if ((cb & 0xC0) != 0x80) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
cp = (cp << 6) | (cb & 0x3F);
|
||||
}
|
||||
|
||||
if (valid && isRenderableCodepoint(cp)) {
|
||||
for (int j = 0; j < seqLen; j++) {
|
||||
currentWord += html[i + j];
|
||||
}
|
||||
}
|
||||
// else: silently skip non-renderable character
|
||||
|
||||
i += valid ? seqLen : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushWord();
|
||||
return atoms;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word-wrap the parsed HTML atoms into positioned line segments
|
||||
// ---------------------------------------------------------------------------
|
||||
void DictionaryDefinitionActivity::wrapText() {
|
||||
wrappedLines.clear();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int topArea = 50;
|
||||
constexpr int bottomArea = 50;
|
||||
const int maxWidth = screenWidth - 2 * sidePadding;
|
||||
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
|
||||
|
||||
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
|
||||
if (linesPerPage < 1) linesPerPage = 1;
|
||||
|
||||
auto atoms = parseHtml(definition);
|
||||
|
||||
std::vector<Segment> currentLine;
|
||||
int currentX = 0;
|
||||
int baseIndent = 0; // indent for continuation lines within the same block
|
||||
|
||||
for (const auto& atom : atoms) {
|
||||
// ---- Newline directive ----
|
||||
if (atom.isNewline) {
|
||||
// Collapse multiple consecutive blank lines
|
||||
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
|
||||
// Already have a blank line; update indent but don't push another
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- Text word ----
|
||||
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
|
||||
int gap = (currentX > baseIndent) ? spaceWidth : 0;
|
||||
|
||||
// Wrap if this word won't fit
|
||||
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
currentX = baseIndent;
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
int16_t x = static_cast<int16_t>(currentX + gap);
|
||||
currentLine.push_back({atom.text, x, atom.style});
|
||||
currentX = x + wordWidth;
|
||||
}
|
||||
|
||||
// Flush last line
|
||||
if (!currentLine.empty()) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
}
|
||||
|
||||
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
|
||||
if (totalPages < 1) totalPages = 1;
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::loop() {
|
||||
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPage && currentPage > 0) {
|
||||
currentPage--;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
if (nextPage && currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int titleY = 10;
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
constexpr int bodyStartY = 50;
|
||||
|
||||
// Title: the word in bold (UI font)
|
||||
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Separator line
|
||||
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
|
||||
|
||||
// Body: styled definition lines
|
||||
int startLine = currentPage * linesPerPage;
|
||||
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
|
||||
int y = bodyStartY + i * lineHeight;
|
||||
const auto& line = wrappedLines[startLine + i];
|
||||
for (const auto& seg : line) {
|
||||
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination indicator (bottom right)
|
||||
if (totalPages > 1) {
|
||||
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
|
||||
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
|
||||
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");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Side button hints (drawn in portrait coordinates for correct placement)
|
||||
{
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth();
|
||||
|
||||
constexpr int sideButtonWidth = 30;
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345;
|
||||
constexpr int cornerRadius = 6;
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
|
||||
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
|
||||
} else {
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
|
||||
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
|
||||
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
|
||||
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
|
||||
firstRender = false;
|
||||
}
|
||||
74
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
74
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
#include <EpdFontFamily.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
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)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(headword),
|
||||
definition(definition),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
|
||||
struct Segment {
|
||||
std::string text;
|
||||
int16_t x;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
// An intermediate token produced by the HTML parser before word-wrapping.
|
||||
struct TextAtom {
|
||||
std::string text;
|
||||
EpdFontFamily::Style style;
|
||||
bool isNewline;
|
||||
int indent; // pixels to indent the new line (for nested lists)
|
||||
};
|
||||
|
||||
// Tracks ordered/unordered list nesting during HTML parsing.
|
||||
struct ListState {
|
||||
int counter; // incremented per <li>, 0 = not yet used
|
||||
bool isAlpha; // true for list-style-type: lower-alpha
|
||||
};
|
||||
|
||||
std::string headword;
|
||||
std::string definition;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
|
||||
std::vector<std::vector<Segment>> wrappedLines;
|
||||
int currentPage = 0;
|
||||
int linesPerPage = 0;
|
||||
int totalPages = 0;
|
||||
bool updateRequired = false;
|
||||
bool firstRender = true;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
std::vector<TextAtom> parseHtml(const std::string& html);
|
||||
static std::string decodeEntity(const std::string& entity);
|
||||
static bool isRenderableCodepoint(uint32_t cp);
|
||||
void wrapText();
|
||||
void renderScreen();
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
};
|
||||
541
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
541
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
@@ -0,0 +1,541 @@
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
void DictionaryWordSelectActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<DictionaryWordSelectActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
extractWords();
|
||||
mergeHyphenatedWords();
|
||||
if (!rows.empty()) {
|
||||
currentRow = static_cast<int>(rows.size()) / 3;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
updateRequired = true;
|
||||
xTaskCreate(&DictionaryWordSelectActivity::taskTrampoline, "DictWordSelTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onExit() {
|
||||
Activity::onExit();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
bool DictionaryWordSelectActivity::isLandscape() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
}
|
||||
|
||||
bool DictionaryWordSelectActivity::isInverted() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::extractWords() {
|
||||
words.clear();
|
||||
rows.clear();
|
||||
|
||||
for (const auto& element : page->elements) {
|
||||
// PageLine is the only concrete PageElement type, identified by tag
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
|
||||
const auto& block = line->getBlock();
|
||||
if (!block) continue;
|
||||
|
||||
const auto& wordList = block->getWords();
|
||||
const auto& xPosList = block->getWordXpos();
|
||||
|
||||
auto wordIt = wordList.begin();
|
||||
auto xIt = xPosList.begin();
|
||||
|
||||
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());
|
||||
|
||||
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
|
||||
++wordIt;
|
||||
++xIt;
|
||||
}
|
||||
}
|
||||
|
||||
// Group words into rows by Y position
|
||||
if (words.empty()) return;
|
||||
|
||||
int16_t currentY = words[0].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
// Allow small Y tolerance (words on same line may differ by a pixel)
|
||||
if (std::abs(words[i].screenY - currentY) > 2) {
|
||||
currentY = words[i].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
}
|
||||
words[i].row = static_cast<int16_t>(rows.size() - 1);
|
||||
rows.back().wordIndices.push_back(static_cast<int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
|
||||
for (size_t r = 0; r + 1 < rows.size(); r++) {
|
||||
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
|
||||
|
||||
int lastWordIdx = rows[r].wordIndices.back();
|
||||
const std::string& lastWord = words[lastWordIdx].text;
|
||||
if (lastWord.empty()) continue;
|
||||
|
||||
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
|
||||
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) continue;
|
||||
|
||||
int nextWordIdx = rows[r + 1].wordIndices.front();
|
||||
|
||||
// Set bidirectional continuation links for highlighting both parts
|
||||
words[lastWordIdx].continuationIndex = nextWordIdx;
|
||||
words[nextWordIdx].continuationOf = lastWordIdx;
|
||||
|
||||
// Build merged lookup text: remove trailing hyphen and combine
|
||||
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 + words[nextWordIdx].text;
|
||||
words[lastWordIdx].lookupText = merged;
|
||||
words[nextWordIdx].lookupText = merged;
|
||||
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
// Button mapping depends on physical orientation:
|
||||
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
|
||||
// - Inverted: same axes but reversed directions (device is flipped 180)
|
||||
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
|
||||
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
} else if (landscape) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
} else if (inverted) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
} else {
|
||||
// Portrait (default)
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
}
|
||||
|
||||
const int rowCount = static_cast<int>(rows.size());
|
||||
|
||||
// Helper: find closest word by X position in a target row
|
||||
auto findClosestWord = [&](int targetRow) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
|
||||
int bestMatch = 0;
|
||||
int bestDist = INT_MAX;
|
||||
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
|
||||
int idx = rows[targetRow].wordIndices[i];
|
||||
int centerX = words[idx].screenX + words[idx].width / 2;
|
||||
int dist = std::abs(centerX - currentCenterX);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestMatch = i;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
// Move to previous row (wrap to bottom)
|
||||
if (rowPrevPressed) {
|
||||
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next row (wrap to top)
|
||||
if (rowNextPressed) {
|
||||
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to previous word (wrap to end of previous row)
|
||||
if (wordPrevPressed) {
|
||||
if (currentWordInRow > 0) {
|
||||
currentWordInRow--;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next word (wrap to start of next row)
|
||||
if (wordNextPressed) {
|
||||
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
|
||||
currentWordInRow++;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const std::string& rawWord = words[wordIdx].lookupText;
|
||||
std::string cleaned = Dictionary::cleanWord(rawWord);
|
||||
|
||||
if (cleaned.empty()) {
|
||||
GUI.drawPopup(renderer, "No word");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show looking up popup, then release mutex so display task can run
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
xSemaphoreGive(renderingMutex);
|
||||
|
||||
bool cancelled = false;
|
||||
std::string definition = Dictionary::lookup(
|
||||
cleaned,
|
||||
[this, &popupLayout](int percent) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
},
|
||||
[this, &cancelled]() -> bool {
|
||||
mappedInput.update();
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
cancelled = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.empty()) {
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
onLookup(cleaned, definition);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Render the page content
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& w = words[wordIdx];
|
||||
|
||||
// Draw inverted highlight behind selected word
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
|
||||
|
||||
// Highlight the other half of a hyphenated word (whether selecting first or second part)
|
||||
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
|
||||
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
|
||||
otherIdx = w.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
|
||||
}
|
||||
}
|
||||
|
||||
drawHints();
|
||||
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::drawHints() {
|
||||
// Draw button hints in portrait orientation (matching physical buttons and theme).
|
||||
// Any hint whose area would overlap the selected word highlight is completely skipped,
|
||||
// leaving the page content underneath visible.
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
|
||||
// Get portrait dimensions for overlap math
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth(); // 480 in portrait
|
||||
const int portH = renderer.getScreenHeight(); // 800 in portrait
|
||||
renderer.setOrientation(origOrientation);
|
||||
|
||||
// Bottom button constants (match LyraTheme::drawButtonHints)
|
||||
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
|
||||
constexpr int buttonWidth = 80;
|
||||
constexpr int cornerRadius = 6;
|
||||
constexpr int textYOffset = 7;
|
||||
constexpr int smallButtonHeight = 15;
|
||||
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
||||
|
||||
// Side button constants (match LyraTheme::drawSideButtonHints)
|
||||
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345; // topHintButtonY
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
|
||||
// Labels for face and side buttons depend on orientation,
|
||||
// because the physical-to-logical mapping rotates with the screen.
|
||||
const char* facePrev; // label for physical Left face button
|
||||
const char* faceNext; // label for physical Right face button
|
||||
const char* sideTop; // label for physical top side button (PageBack)
|
||||
const char* sideBottom; // label for physical bottom side button (PageForward)
|
||||
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
facePrev = "Line Up"; faceNext = "Line Dn";
|
||||
sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word";
|
||||
} else if (landscape) { // LANDSCAPE_CCW
|
||||
facePrev = "Line Dn"; faceNext = "Line Up";
|
||||
sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB";
|
||||
} else if (inverted) {
|
||||
facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word";
|
||||
sideTop = "Line Dn"; sideBottom = "Line Up";
|
||||
} else { // Portrait (default)
|
||||
facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB";
|
||||
sideTop = "Line Up"; sideBottom = "Line Dn";
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
|
||||
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
|
||||
const char* sideLabels[] = {sideTop, sideBottom};
|
||||
|
||||
// ---- Determine which hints overlap the selected word ----
|
||||
bool hideHint[4] = {false, false, false, false};
|
||||
bool hideSide[2] = {false, false};
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
|
||||
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
|
||||
struct Box {
|
||||
int x, y, w, h;
|
||||
};
|
||||
Box boxes[2];
|
||||
int boxCount = 0;
|
||||
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& sel = words[wordIdx];
|
||||
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
|
||||
boxCount = 1;
|
||||
|
||||
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
|
||||
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
|
||||
otherIdx = sel.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
|
||||
boxCount = 2;
|
||||
}
|
||||
|
||||
// Convert each box from the current orientation to portrait coordinates,
|
||||
// then check overlap against both bottom and side button hints.
|
||||
for (int b = 0; b < boxCount; b++) {
|
||||
int px, py, pw, ph;
|
||||
|
||||
if (origOrientation == GfxRenderer::Orientation::Portrait) {
|
||||
px = boxes[b].x;
|
||||
py = boxes[b].y;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
|
||||
px = portW - boxes[b].x - boxes[b].w;
|
||||
py = portH - boxes[b].y - boxes[b].h;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
|
||||
px = boxes[b].y;
|
||||
py = portH - boxes[b].x - boxes[b].w;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
} else {
|
||||
px = portW - boxes[b].y - boxes[b].h;
|
||||
py = boxes[b].x;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
}
|
||||
|
||||
// Bottom button overlap
|
||||
int hintTop = portH - buttonHeight;
|
||||
if (py + ph > hintTop) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
|
||||
hideHint[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Side button overlap
|
||||
if (px + pw > sideX) {
|
||||
for (int s = 0; s < 2; s++) {
|
||||
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
|
||||
hideSide[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Draw all hints in portrait mode ----
|
||||
// Hidden buttons are skipped entirely so the page content underneath stays visible.
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
// Bottom face buttons
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (hideHint[i]) continue;
|
||||
|
||||
const int x = buttonPositions[i];
|
||||
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
|
||||
|
||||
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
|
||||
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
||||
false, true);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
|
||||
const int tx = x + (buttonWidth - 1 - tw) / 2;
|
||||
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
|
||||
} else {
|
||||
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
||||
true, false, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (hideSide[i]) continue;
|
||||
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
|
||||
|
||||
// Solid background
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
|
||||
// Outline (rounded on inner side, square on screen edge — matches theme)
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
// Truncate text if it would overflow the button height
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
|
||||
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
|
||||
} else {
|
||||
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
|
||||
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
80
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
80
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
@@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
#include <Epub/Page.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
class DictionaryWordSelectActivity final : public Activity {
|
||||
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),
|
||||
page(std::move(page)),
|
||||
fontId(fontId),
|
||||
marginLeft(marginLeft),
|
||||
marginTop(marginTop),
|
||||
cachePath(cachePath),
|
||||
orientation(orientation),
|
||||
onBack(onBack),
|
||||
onLookup(onLookup) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
struct WordInfo {
|
||||
std::string text;
|
||||
std::string lookupText;
|
||||
int16_t screenX;
|
||||
int16_t screenY;
|
||||
int16_t width;
|
||||
int16_t row;
|
||||
int continuationIndex;
|
||||
int continuationOf;
|
||||
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
|
||||
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
|
||||
};
|
||||
|
||||
struct Row {
|
||||
int16_t yPos;
|
||||
std::vector<int> wordIndices;
|
||||
};
|
||||
|
||||
std::unique_ptr<Page> page;
|
||||
int fontId;
|
||||
int marginLeft;
|
||||
int marginTop;
|
||||
std::string cachePath;
|
||||
uint8_t orientation;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void(const std::string&, const std::string&)> onLookup;
|
||||
|
||||
std::vector<WordInfo> words;
|
||||
std::vector<Row> rows;
|
||||
int currentRow = 0;
|
||||
int currentWordInRow = 0;
|
||||
bool updateRequired = false;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
bool isLandscape() const;
|
||||
bool isInverted() const;
|
||||
void extractWords();
|
||||
void mergeHyphenatedWords();
|
||||
void renderScreen();
|
||||
void drawHints();
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
};
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
namespace {
|
||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||
@@ -232,10 +234,11 @@ void EpubReaderActivity::loop() {
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
const bool hasDictionary = Dictionary::exists();
|
||||
exitActivity();
|
||||
enterNewActivity(new EpubReaderMenuActivity(
|
||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
SETTINGS.orientation, hasDictionary, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
@@ -396,6 +399,40 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
||||
|
||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||
switch (action) {
|
||||
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||
// Stub — bookmark feature coming soon
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
||||
// Stub — bookmark feature coming soon
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Coming soon");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
||||
if (Dictionary::cacheExists()) {
|
||||
Dictionary::deleteCache();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "Dictionary cache deleted");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
} else {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
GUI.drawPopup(renderer, "No cache to delete");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||
// Calculate values BEFORE we start destroying things
|
||||
const int currentP = section ? section->currentPage : 0;
|
||||
@@ -463,6 +500,92 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
xSemaphoreGive(renderingMutex);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::LOOKUP: {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
|
||||
// Compute margins (same logic as renderScreen)
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += SETTINGS.screenMargin;
|
||||
orientedMarginLeft += SETTINGS.screenMargin;
|
||||
orientedMarginRight += SETTINGS.screenMargin;
|
||||
orientedMarginBottom += SETTINGS.screenMargin;
|
||||
|
||||
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool showProgressBar =
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR;
|
||||
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
|
||||
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||
}
|
||||
|
||||
// Load the current page
|
||||
auto pageForLookup = section ? section->loadPageFromSectionFile() : nullptr;
|
||||
const int readerFontId = SETTINGS.getReaderFontId();
|
||||
const std::string bookCachePath = epub->getCachePath();
|
||||
const uint8_t currentOrientation = SETTINGS.orientation;
|
||||
|
||||
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; }));
|
||||
}));
|
||||
}
|
||||
|
||||
xSemaphoreGive(renderingMutex);
|
||||
break;
|
||||
}
|
||||
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; }));
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
||||
// Defer go home to avoid race condition with display task
|
||||
pendingGoHome = true;
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "LookedUpWordsActivity.h"
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||
|
||||
@@ -14,13 +14,27 @@
|
||||
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
// Menu actions available from the reader menu.
|
||||
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
|
||||
enum class MenuAction {
|
||||
ADD_BOOKMARK,
|
||||
LOOKUP,
|
||||
LOOKED_UP_WORDS,
|
||||
ROTATE_SCREEN,
|
||||
SELECT_CHAPTER,
|
||||
GO_TO_BOOKMARK,
|
||||
GO_TO_PERCENT,
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
DELETE_CACHE,
|
||||
DELETE_DICT_CACHE
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
|
||||
const uint8_t currentOrientation, const bool hasDictionary,
|
||||
const std::function<void(uint8_t)>& onBack,
|
||||
const std::function<void(MenuAction)>& onAction)
|
||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||
menuItems(buildMenuItems(hasDictionary)),
|
||||
title(title),
|
||||
pendingOrientation(currentOrientation),
|
||||
currentPage(currentPage),
|
||||
@@ -39,11 +53,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
std::string label;
|
||||
};
|
||||
|
||||
// Fixed menu layout (order matters for up/down navigation).
|
||||
const std::vector<MenuItem> menuItems = {
|
||||
{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"},
|
||||
{MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"},
|
||||
{MenuAction::SYNC, "Sync Progress"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
|
||||
std::vector<MenuItem> menuItems;
|
||||
|
||||
int selectedIndex = 0;
|
||||
bool updateRequired = false;
|
||||
@@ -60,6 +70,26 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||
const std::function<void(uint8_t)> onBack;
|
||||
const std::function<void(MenuAction)> onAction;
|
||||
|
||||
static std::vector<MenuItem> buildMenuItems(bool hasDictionary) {
|
||||
std::vector<MenuItem> items;
|
||||
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
|
||||
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
|
||||
}
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"});
|
||||
items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"});
|
||||
items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"});
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});
|
||||
items.push_back({MenuAction::GO_HOME, "Close Book"});
|
||||
items.push_back({MenuAction::SYNC, "Sync Progress"});
|
||||
items.push_back({MenuAction::DELETE_CACHE, "Delete Book Cache"});
|
||||
if (hasDictionary) {
|
||||
items.push_back({MenuAction::DELETE_DICT_CACHE, "Delete Dictionary Cache"});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void renderScreen();
|
||||
|
||||
196
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
196
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
@@ -0,0 +1,196 @@
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
void LookedUpWordsActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<LookedUpWordsActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired && !subActivity) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
renderScreen();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::onEnter() {
|
||||
ActivityWithSubactivity::onEnter();
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
words = LookupHistory::load(cachePath);
|
||||
updateRequired = true;
|
||||
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::loop() {
|
||||
if (subActivity) {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||
if (deleteConfirmMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
// Ignore the release from the initial long press
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
// Confirm delete
|
||||
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
|
||||
words.erase(words.begin() + pendingDeleteIndex);
|
||||
if (selectedIndex >= static_cast<int>(words.size())) {
|
||||
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
|
||||
}
|
||||
deleteConfirmMode = false;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
deleteConfirmMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
updateRequired = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect long press on Confirm to trigger delete
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectedIndex;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size()));
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size()));
|
||||
updateRequired = true;
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
onSelectWord(words[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::renderScreen() {
|
||||
renderer.clearScreen();
|
||||
|
||||
constexpr int sidePadding = 20;
|
||||
constexpr int titleY = 15;
|
||||
constexpr int startY = 60;
|
||||
constexpr int lineHeight = 30;
|
||||
|
||||
// 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);
|
||||
|
||||
if (words.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 300, "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);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
|
||||
// Draw delete confirmation overlay
|
||||
const std::string& word = words[pendingDeleteIndex];
|
||||
std::string displayWord = word;
|
||||
if (displayWord.size() > 20) {
|
||||
displayWord.erase(17);
|
||||
displayWord += "...";
|
||||
}
|
||||
std::string msg = "Delete '" + displayWord + "'?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
constexpr int popupY = 200;
|
||||
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;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
|
||||
const int textX = x + (w - textWidth) / 2;
|
||||
const int textY = popupY + margin - 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Button hints for delete mode
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
// "Hold select to delete" hint above button hints
|
||||
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,
|
||||
deleteHint);
|
||||
}
|
||||
|
||||
// Normal button hints
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
48
src/activities/reader/LookedUpWordsActivity.h
Normal file
48
src/activities/reader/LookedUpWordsActivity.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#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 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)
|
||||
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
|
||||
cachePath(cachePath),
|
||||
onBack(onBack),
|
||||
onSelectWord(onSelectWord) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
const std::function<void()> onBack;
|
||||
const std::function<void(const std::string&)> onSelectWord;
|
||||
|
||||
std::vector<std::string> words;
|
||||
int selectedIndex = 0;
|
||||
bool updateRequired = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
// Delete confirmation state
|
||||
bool deleteConfirmMode = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
void renderScreen();
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
};
|
||||
328
src/util/Dictionary.cpp
Normal file
328
src/util/Dictionary.cpp
Normal file
@@ -0,0 +1,328 @@
|
||||
#include "Dictionary.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
constexpr const char* IDX_PATH = "/.dictionary/dictionary.idx";
|
||||
constexpr const char* DICT_PATH = "/.dictionary/dictionary.dict";
|
||||
constexpr const char* CACHE_PATH = "/.dictionary/dictionary.cache";
|
||||
constexpr uint32_t CACHE_MAGIC = 0x44494358; // "DICX"
|
||||
|
||||
// g_ascii_strcasecmp equivalent: compare lowercasing only ASCII A-Z.
|
||||
int asciiCaseCmp(const char* s1, const char* s2) {
|
||||
const auto* p1 = reinterpret_cast<const unsigned char*>(s1);
|
||||
const auto* p2 = reinterpret_cast<const unsigned char*>(s2);
|
||||
while (*p1 && *p2) {
|
||||
unsigned char c1 = *p1, c2 = *p2;
|
||||
if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
|
||||
if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
|
||||
if (c1 != c2) return static_cast<int>(c1) - static_cast<int>(c2);
|
||||
++p1;
|
||||
++p2;
|
||||
}
|
||||
return static_cast<int>(*p1) - static_cast<int>(*p2);
|
||||
}
|
||||
|
||||
// StarDict index comparison: case-insensitive first, then case-sensitive tiebreaker.
|
||||
// This matches the stardict_strcmp used by StarDict to sort .idx entries.
|
||||
int stardictCmp(const char* s1, const char* s2) {
|
||||
int ci = asciiCaseCmp(s1, s2);
|
||||
if (ci != 0) return ci;
|
||||
return std::strcmp(s1, s2);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<uint32_t> Dictionary::sparseOffsets;
|
||||
uint32_t Dictionary::totalWords = 0;
|
||||
bool Dictionary::indexLoaded = false;
|
||||
|
||||
bool Dictionary::exists() { return Storage.exists(IDX_PATH); }
|
||||
|
||||
bool Dictionary::cacheExists() { return Storage.exists(CACHE_PATH); }
|
||||
|
||||
void Dictionary::deleteCache() {
|
||||
Storage.remove(CACHE_PATH);
|
||||
// Reset in-memory state so next lookup rebuilds from the .idx file.
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
indexLoaded = false;
|
||||
}
|
||||
|
||||
std::string Dictionary::cleanWord(const std::string& word) {
|
||||
if (word.empty()) return "";
|
||||
|
||||
// Find first alphanumeric character
|
||||
size_t start = 0;
|
||||
while (start < word.size() && !std::isalnum(static_cast<unsigned char>(word[start]))) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// Find last alphanumeric character
|
||||
size_t end = word.size();
|
||||
while (end > start && !std::isalnum(static_cast<unsigned char>(word[end - 1]))) {
|
||||
end--;
|
||||
}
|
||||
|
||||
if (start >= end) return "";
|
||||
|
||||
std::string result = word.substr(start, end - start);
|
||||
// Lowercase
|
||||
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache: persists the sparse offset table to SD card so subsequent boots skip
|
||||
// the full .idx scan. The cache is invalidated when the .idx file size changes.
|
||||
//
|
||||
// Format: [magic 4B][idxFileSize 4B][totalWords 4B][count 4B][offsets N×4B]
|
||||
// All values are stored in native byte order (little-endian on ESP32).
|
||||
// ---------------------------------------------------------------------------
|
||||
bool Dictionary::loadCachedIndex() {
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||
const uint32_t idxFileSize = static_cast<uint32_t>(idx.fileSize());
|
||||
idx.close();
|
||||
|
||||
FsFile cache;
|
||||
if (!Storage.openFileForRead("DICT", CACHE_PATH, cache)) return false;
|
||||
|
||||
// Read and validate header
|
||||
uint32_t header[4]; // magic, idxFileSize, totalWords, count
|
||||
if (cache.read(reinterpret_cast<uint8_t*>(header), 16) != 16) {
|
||||
cache.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (header[0] != CACHE_MAGIC || header[1] != idxFileSize) {
|
||||
cache.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
totalWords = header[2];
|
||||
const uint32_t count = header[3];
|
||||
|
||||
sparseOffsets.resize(count);
|
||||
const int bytesToRead = static_cast<int>(count * sizeof(uint32_t));
|
||||
if (cache.read(reinterpret_cast<uint8_t*>(sparseOffsets.data()), bytesToRead) != bytesToRead) {
|
||||
cache.close();
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
cache.close();
|
||||
indexLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Dictionary::saveCachedIndex(uint32_t idxFileSize) {
|
||||
FsFile cache;
|
||||
if (!Storage.openFileForWrite("DICT", CACHE_PATH, cache)) return;
|
||||
|
||||
const uint32_t count = static_cast<uint32_t>(sparseOffsets.size());
|
||||
uint32_t header[4] = {CACHE_MAGIC, idxFileSize, totalWords, count};
|
||||
|
||||
cache.write(reinterpret_cast<const uint8_t*>(header), 16);
|
||||
cache.write(reinterpret_cast<const uint8_t*>(sparseOffsets.data()), count * sizeof(uint32_t));
|
||||
cache.close();
|
||||
}
|
||||
|
||||
// Scan the .idx file to build a sparse offset table for fast lookups.
|
||||
// Records the file offset of every SPARSE_INTERVAL-th entry.
|
||||
bool Dictionary::loadIndex(const std::function<void(int percent)>& onProgress,
|
||||
const std::function<bool()>& shouldCancel) {
|
||||
// Try loading from cache first (nearly instant)
|
||||
if (loadCachedIndex()) return true;
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||
|
||||
const uint32_t fileSize = static_cast<uint32_t>(idx.fileSize());
|
||||
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
|
||||
uint32_t pos = 0;
|
||||
int lastReportedPercent = -1;
|
||||
|
||||
while (pos < fileSize) {
|
||||
if (shouldCancel && (totalWords % 100 == 0) && shouldCancel()) {
|
||||
idx.close();
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (totalWords % SPARSE_INTERVAL == 0) {
|
||||
sparseOffsets.push_back(pos);
|
||||
}
|
||||
|
||||
// Skip word (read until null terminator)
|
||||
int ch;
|
||||
do {
|
||||
ch = idx.read();
|
||||
if (ch < 0) {
|
||||
pos = fileSize;
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
} while (ch != 0);
|
||||
|
||||
if (pos >= fileSize) break;
|
||||
|
||||
// Skip 8 bytes (4-byte offset + 4-byte size)
|
||||
uint8_t skip[8];
|
||||
if (idx.read(skip, 8) != 8) break;
|
||||
pos += 8;
|
||||
|
||||
totalWords++;
|
||||
|
||||
if (onProgress && fileSize > 0) {
|
||||
int percent = static_cast<int>(static_cast<uint64_t>(pos) * 90 / fileSize);
|
||||
if (percent > lastReportedPercent + 4) {
|
||||
lastReportedPercent = percent;
|
||||
onProgress(percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
indexLoaded = true;
|
||||
|
||||
// Persist to cache so next boot is instant
|
||||
if (totalWords > 0) saveCachedIndex(fileSize);
|
||||
|
||||
return totalWords > 0;
|
||||
}
|
||||
|
||||
// Read a null-terminated word string from the current file position.
|
||||
std::string Dictionary::readWord(FsFile& file) {
|
||||
std::string word;
|
||||
while (true) {
|
||||
int ch = file.read();
|
||||
if (ch <= 0) break; // null terminator (0) or error (-1)
|
||||
word += static_cast<char>(ch);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// Read a definition from the .dict file at the given offset and size.
|
||||
std::string Dictionary::readDefinition(uint32_t offset, uint32_t size) {
|
||||
FsFile dict;
|
||||
if (!Storage.openFileForRead("DICT", DICT_PATH, dict)) return "";
|
||||
|
||||
dict.seekSet(offset);
|
||||
|
||||
std::string def(size, '\0');
|
||||
int bytesRead = dict.read(reinterpret_cast<uint8_t*>(&def[0]), size);
|
||||
dict.close();
|
||||
|
||||
if (bytesRead < 0) return "";
|
||||
if (static_cast<uint32_t>(bytesRead) < size) def.resize(bytesRead);
|
||||
return def;
|
||||
}
|
||||
|
||||
// Binary search the sparse offset table, then linear scan within the matching segment.
|
||||
// Uses StarDict's sort order: case-insensitive first, then case-sensitive tiebreaker.
|
||||
// The exact match is case-insensitive so e.g. "simple" matches "Simple".
|
||||
std::string Dictionary::searchIndex(const std::string& word, const std::function<bool()>& shouldCancel) {
|
||||
if (sparseOffsets.empty()) return "";
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return "";
|
||||
|
||||
// Binary search the sparse offset table to find the right segment.
|
||||
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
|
||||
|
||||
while (lo < hi) {
|
||||
if (shouldCancel && shouldCancel()) {
|
||||
idx.close();
|
||||
return "";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Linear scan within the segment starting at sparseOffsets[lo].
|
||||
idx.seekSet(sparseOffsets[lo]);
|
||||
|
||||
int maxEntries = SPARSE_INTERVAL;
|
||||
if (lo == static_cast<int>(sparseOffsets.size()) - 1) {
|
||||
maxEntries = static_cast<int>(totalWords - static_cast<uint32_t>(lo) * SPARSE_INTERVAL);
|
||||
}
|
||||
|
||||
// Scan entries, preferring an exact case-sensitive match over a case-insensitive one.
|
||||
// In stardict order, all case variants of a word are adjacent (e.g. "Professor" then "professor"),
|
||||
// and they may have different definitions. We want the lowercase entry when the user searched
|
||||
// for a lowercase word, falling back to any case variant.
|
||||
uint32_t bestOffset = 0, bestSize = 0;
|
||||
bool found = false;
|
||||
|
||||
for (int i = 0; i < maxEntries; i++) {
|
||||
if (shouldCancel && shouldCancel()) {
|
||||
idx.close();
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string key = readWord(idx);
|
||||
if (key.empty()) break;
|
||||
|
||||
// Read offset and size (4 bytes each, big-endian)
|
||||
uint8_t buf[8];
|
||||
if (idx.read(buf, 8) != 8) break;
|
||||
|
||||
uint32_t dictOffset = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
|
||||
(static_cast<uint32_t>(buf[2]) << 8) | static_cast<uint32_t>(buf[3]);
|
||||
uint32_t dictSize = (static_cast<uint32_t>(buf[4]) << 24) | (static_cast<uint32_t>(buf[5]) << 16) |
|
||||
(static_cast<uint32_t>(buf[6]) << 8) | static_cast<uint32_t>(buf[7]);
|
||||
|
||||
if (asciiCaseCmp(key.c_str(), word.c_str()) == 0) {
|
||||
// Case-insensitive match — remember the first one as fallback
|
||||
if (!found) {
|
||||
bestOffset = dictOffset;
|
||||
bestSize = dictSize;
|
||||
found = true;
|
||||
}
|
||||
// Exact case-sensitive match — use immediately
|
||||
if (key == word) {
|
||||
idx.close();
|
||||
return readDefinition(dictOffset, dictSize);
|
||||
}
|
||||
} else if (found) {
|
||||
// We've moved past all case variants of this word — stop
|
||||
break;
|
||||
} else if (stardictCmp(key.c_str(), word.c_str()) > 0) {
|
||||
// Past the target in StarDict sort order — stop scanning
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
return found ? readDefinition(bestOffset, bestSize) : "";
|
||||
}
|
||||
|
||||
std::string Dictionary::lookup(const std::string& word, const std::function<void(int percent)>& onProgress,
|
||||
const std::function<bool()>& shouldCancel) {
|
||||
if (!indexLoaded) {
|
||||
if (!loadIndex(onProgress, shouldCancel)) return "";
|
||||
}
|
||||
|
||||
// searchIndex uses StarDict sort order + case-insensitive match,
|
||||
// so a single pass handles all casing variants.
|
||||
std::string result = searchIndex(word, shouldCancel);
|
||||
if (onProgress) onProgress(100);
|
||||
return result;
|
||||
}
|
||||
31
src/util/Dictionary.h
Normal file
31
src/util/Dictionary.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class FsFile;
|
||||
|
||||
class Dictionary {
|
||||
public:
|
||||
static bool exists();
|
||||
static bool cacheExists();
|
||||
static void deleteCache();
|
||||
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);
|
||||
|
||||
private:
|
||||
static constexpr int SPARSE_INTERVAL = 512;
|
||||
|
||||
static std::vector<uint32_t> sparseOffsets;
|
||||
static uint32_t totalWords;
|
||||
static bool indexLoaded;
|
||||
|
||||
static bool loadIndex(const std::function<void(int percent)>& onProgress, const std::function<bool()>& shouldCancel);
|
||||
static bool loadCachedIndex();
|
||||
static void saveCachedIndex(uint32_t idxFileSize);
|
||||
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);
|
||||
};
|
||||
88
src/util/LookupHistory.cpp
Normal file
88
src/util/LookupHistory.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include "LookupHistory.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
std::string LookupHistory::filePath(const std::string& cachePath) { return cachePath + "/lookups.txt"; }
|
||||
|
||||
bool LookupHistory::hasHistory(const std::string& cachePath) {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||
return false;
|
||||
}
|
||||
bool nonEmpty = f.available() > 0;
|
||||
f.close();
|
||||
return nonEmpty;
|
||||
}
|
||||
|
||||
std::vector<std::string> LookupHistory::load(const std::string& cachePath) {
|
||||
std::vector<std::string> words;
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||
return words;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
while (f.available() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||
char c;
|
||||
if (f.read(reinterpret_cast<uint8_t*>(&c), 1) != 1) break;
|
||||
if (c == '\n') {
|
||||
if (!line.empty()) {
|
||||
words.push_back(line);
|
||||
line.clear();
|
||||
}
|
||||
} else {
|
||||
line += c;
|
||||
}
|
||||
}
|
||||
if (!line.empty() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||
words.push_back(line);
|
||||
}
|
||||
f.close();
|
||||
return words;
|
||||
}
|
||||
|
||||
void LookupHistory::removeWord(const std::string& cachePath, const std::string& word) {
|
||||
if (word.empty()) return;
|
||||
|
||||
auto existing = load(cachePath);
|
||||
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& w : existing) {
|
||||
if (w != word) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
|
||||
void LookupHistory::addWord(const std::string& cachePath, const std::string& word) {
|
||||
if (word.empty()) return;
|
||||
|
||||
// Check if already present
|
||||
auto existing = load(cachePath);
|
||||
if (std::any_of(existing.begin(), existing.end(), [&word](const std::string& w) { return w == word; })) return;
|
||||
|
||||
// Cap at max entries
|
||||
if (static_cast<int>(existing.size()) >= MAX_ENTRIES) return;
|
||||
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewrite existing entries plus new one
|
||||
for (const auto& w : existing) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
}
|
||||
f.write(reinterpret_cast<const uint8_t*>(word.c_str()), word.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
f.close();
|
||||
}
|
||||
15
src/util/LookupHistory.h
Normal file
15
src/util/LookupHistory.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class LookupHistory {
|
||||
public:
|
||||
static std::vector<std::string> load(const std::string& cachePath);
|
||||
static void addWord(const std::string& cachePath, const std::string& word);
|
||||
static void removeWord(const std::string& cachePath, const std::string& word);
|
||||
static bool hasHistory(const std::string& cachePath);
|
||||
|
||||
private:
|
||||
static std::string filePath(const std::string& cachePath);
|
||||
static constexpr int MAX_ENTRIES = 500;
|
||||
};
|
||||
Reference in New Issue
Block a user