From 8d4bbf284d9f1c85caf8ebad8a542027c63153f7 Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 12 Feb 2026 19:36:14 -0500 Subject: [PATCH] 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 --- lib/Epub/Epub/Page.h | 1 + lib/Epub/Epub/blocks/TextBlock.h | 3 + lib/GfxRenderer/GfxRenderer.cpp | 86 +++ lib/GfxRenderer/GfxRenderer.h | 4 +- .../reader/DictionaryDefinitionActivity.cpp | 537 +++++++++++++++++ .../reader/DictionaryDefinitionActivity.h | 74 +++ .../reader/DictionaryWordSelectActivity.cpp | 541 ++++++++++++++++++ .../reader/DictionaryWordSelectActivity.h | 80 +++ src/activities/reader/EpubReaderActivity.cpp | 125 +++- src/activities/reader/EpubReaderActivity.h | 3 + .../reader/EpubReaderMenuActivity.h | 44 +- .../reader/LookedUpWordsActivity.cpp | 196 +++++++ src/activities/reader/LookedUpWordsActivity.h | 48 ++ src/util/Dictionary.cpp | 328 +++++++++++ src/util/Dictionary.h | 31 + src/util/LookupHistory.cpp | 88 +++ src/util/LookupHistory.h | 15 + 17 files changed, 2195 insertions(+), 9 deletions(-) create mode 100644 src/activities/reader/DictionaryDefinitionActivity.cpp create mode 100644 src/activities/reader/DictionaryDefinitionActivity.h create mode 100644 src/activities/reader/DictionaryWordSelectActivity.cpp create mode 100644 src/activities/reader/DictionaryWordSelectActivity.h create mode 100644 src/activities/reader/LookedUpWordsActivity.cpp create mode 100644 src/activities/reader/LookedUpWordsActivity.h create mode 100644 src/util/Dictionary.cpp create mode 100644 src/util/Dictionary.h create mode 100644 src/util/LookupHistory.cpp create mode 100644 src/util/LookupHistory.h diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 590e288d..41e1db90 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -28,6 +28,7 @@ class PageLine final : public PageElement { public: PageLine(std::shared_ptr block, const int16_t xPos, const int16_t yPos) : PageElement(xPos, yPos), block(std::move(block)) {} + const std::shared_ptr& getBlock() const { return block; } void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; bool serialize(FsFile& file) override; static std::unique_ptr deserialize(FsFile& file); diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index 536471a9..f506a036 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -27,6 +27,9 @@ class TextBlock final : public Block { ~TextBlock() override = default; void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; } const BlockStyle& getBlockStyle() const { return blockStyle; } + const std::vector& getWords() const { return words; } + const std::vector& getWordXpos() const { return wordXpos; } + const std::vector& getWordStyles() const { return wordStyles; } bool isEmpty() override { return words.empty(); } void layout(GfxRenderer& renderer) override {}; // given a renderer works out where to break the words into lines diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 4128b7fc..81a69f5c 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -905,6 +905,92 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y } } +void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black, + const EpdFontFamily::Style style) const { + // Cannot draw a NULL / empty string + if (text == nullptr || *text == '\0') { + return; + } + + if (fontMap.count(fontId) == 0) { + Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + return; + } + const auto font = fontMap.at(fontId); + + // No printable characters + if (!font.hasPrintableChars(text, style)) { + return; + } + + // For 90° counter-clockwise rotation: + // Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction + // Text reads from top to bottom + + const int advanceY = font.getData(style)->advanceY; + const int ascender = font.getData(style)->ascender; + + int yPos = y; // Current Y position (increases as we draw characters) + + uint32_t cp; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph* glyph = font.getGlyph(cp, style); + if (!glyph) { + glyph = font.getGlyph(REPLACEMENT_GLYPH, style); + } + if (!glyph) { + continue; + } + + const int is2Bit = font.getData(style)->is2Bit; + const uint32_t offset = glyph->dataOffset; + const uint8_t width = glyph->width; + const uint8_t height = glyph->height; + const int left = glyph->left; + const int top = glyph->top; + + const uint8_t* bitmap = &font.getData(style)->bitmap[offset]; + + if (bitmap != nullptr) { + for (int glyphY = 0; glyphY < height; glyphY++) { + for (int glyphX = 0; glyphX < width; glyphX++) { + const int pixelPosition = glyphY * width + glyphX; + + // 90° counter-clockwise rotation transformation: + // screenX = mirrored CW X (right-to-left within advanceY span) + // screenY = yPos + (left + glyphX) (downward) + const int screenX = x + advanceY - 1 - (ascender - top + glyphY); + const int screenY = yPos + left + glyphX; + + if (is2Bit) { + const uint8_t byte = bitmap[pixelPosition / 4]; + const uint8_t bit_index = (3 - pixelPosition % 4) * 2; + const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3; + + if (renderMode == BW && bmpVal < 3) { + drawPixel(screenX, screenY, black); + } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { + drawPixel(screenX, screenY, false); + } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { + drawPixel(screenX, screenY, false); + } + } else { + const uint8_t byte = bitmap[pixelPosition / 8]; + const uint8_t bit_index = 7 - (pixelPosition % 8); + + if ((byte >> bit_index) & 1) { + drawPixel(screenX, screenY, black); + } + } + } + } + } + + // Move to next character position (going down, so increase Y) + yPos += glyph->advanceX; + } +} + uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; } size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index c263973d..1804c0c3 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -111,9 +111,11 @@ class GfxRenderer { std::string truncatedText(int fontId, const char* text, int maxWidth, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; - // Helper for drawing rotated text (90 degrees clockwise, for side buttons) + // Helpers for drawing rotated text (for side buttons) void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextHeight(int fontId) const; // Grayscale functions diff --git a/src/activities/reader/DictionaryDefinitionActivity.cpp b/src/activities/reader/DictionaryDefinitionActivity.cpp new file mode 100644 index 00000000..e6d503b9 --- /dev/null +++ b/src/activities/reader/DictionaryDefinitionActivity.cpp @@ -0,0 +1,537 @@ +#include "DictionaryDefinitionActivity.h" + +#include + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +void DictionaryDefinitionActivity::taskTrampoline(void* param) { + auto* self = static_cast(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(cp)); + } + if (cp >= 0x80 && cp < 0x800) { + char buf[3] = {static_cast(0xC0 | (cp >> 6)), static_cast(0x80 | (cp & 0x3F)), '\0'}; + return std::string(buf, 2); + } + if (cp >= 0x800 && cp < 0x10000) { + char buf[4] = {static_cast(0xE0 | (cp >> 12)), static_cast(0x80 | ((cp >> 6) & 0x3F)), + static_cast(0x80 | (cp & 0x3F)), '\0'}; + return std::string(buf, 3); + } + if (cp >= 0x10000 && cp < 0x110000) { + char buf[5] = {static_cast(0xF0 | (cp >> 18)), static_cast(0x80 | ((cp >> 12) & 0x3F)), + static_cast(0x80 | ((cp >> 6) & 0x3F)), static_cast(0x80 | (cp & 0x3F)), '\0'}; + return std::string(buf, 4); + } + } + + return ""; // unknown entity — drop it +} + +// --------------------------------------------------------------------------- +// HTML → TextAtom list +// --------------------------------------------------------------------------- +std::vector DictionaryDefinitionActivity::parseHtml(const std::string& html) { + std::vector atoms; + + bool isBold = false; + bool isItalic = false; + bool inSvg = false; + int svgDepth = 0; + std::vector 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(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(std::tolower(static_cast(c))); + if (!tagName.empty() && tagName.back() == '/') tagName.pop_back(); + + // --- SVG handling (skip all content inside ) --- + 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 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('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
  • + 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(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(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(seqLen) > html.size()) { + i++; + continue; + } + + bool valid = true; + for (int j = 1; j < seqLen; j++) { + unsigned char cb = static_cast(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 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(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(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(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; +} diff --git a/src/activities/reader/DictionaryDefinitionActivity.h b/src/activities/reader/DictionaryDefinitionActivity.h new file mode 100644 index 00000000..648967db --- /dev/null +++ b/src/activities/reader/DictionaryDefinitionActivity.h @@ -0,0 +1,74 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +#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& 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
  • , 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 onBack; + + std::vector> 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 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(); +}; diff --git a/src/activities/reader/DictionaryWordSelectActivity.cpp b/src/activities/reader/DictionaryWordSelectActivity.cpp new file mode 100644 index 00000000..262535b1 --- /dev/null +++ b/src/activities/reader/DictionaryWordSelectActivity.cpp @@ -0,0 +1,541 @@ +#include "DictionaryWordSelectActivity.h" + +#include + +#include +#include + +#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(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(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(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(*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(rows.size() - 1); + rows.back().wordIndices.push_back(static_cast(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(lastWord[lastWord.size() - 2]) == 0xC2 && + static_cast(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(firstPart[firstPart.size() - 2]) == 0xC2 && + static_cast(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(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(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(rows[currentRow].wordIndices.size()) - 1; + } + changed = true; + } + + // Move to next word (wrap to start of next row) + if (wordNextPressed) { + if (currentWordInRow < static_cast(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(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(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); +} diff --git a/src/activities/reader/DictionaryWordSelectActivity.h b/src/activities/reader/DictionaryWordSelectActivity.h new file mode 100644 index 00000000..2d513552 --- /dev/null +++ b/src/activities/reader/DictionaryWordSelectActivity.h @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "../Activity.h" + +class DictionaryWordSelectActivity final : public Activity { + public: + explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::unique_ptr page, int fontId, int marginLeft, int marginTop, + const std::string& cachePath, uint8_t orientation, + const std::function& onBack, + const std::function& onLookup) + : Activity("DictionaryWordSelect", renderer, mappedInput), + 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 wordIndices; + }; + + std::unique_ptr page; + int fontId; + int marginLeft; + int marginTop; + std::string cachePath; + uint8_t orientation; + const std::function onBack; + const std::function onLookup; + + std::vector words; + std::vector 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(); +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 9c292f41..f07173fa 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(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; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 3ec1196a..aed79335 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,7 +5,10 @@ #include #include +#include "DictionaryDefinitionActivity.h" +#include "DictionaryWordSelectActivity.h" #include "EpubReaderMenuActivity.h" +#include "LookedUpWordsActivity.h" #include "activities/ActivityWithSubactivity.h" class EpubReaderActivity final : public ActivityWithSubactivity { diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index 1f34b208..731149b4 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -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& onBack, + const uint8_t currentOrientation, const bool hasDictionary, + const std::function& onBack, const std::function& 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 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 menuItems; int selectedIndex = 0; bool updateRequired = false; @@ -60,6 +70,26 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity { const std::function onBack; const std::function onAction; + static std::vector buildMenuItems(bool hasDictionary) { + std::vector 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(); diff --git a/src/activities/reader/LookedUpWordsActivity.cpp b/src/activities/reader/LookedUpWordsActivity.cpp new file mode 100644 index 00000000..4cf47a59 --- /dev/null +++ b/src/activities/reader/LookedUpWordsActivity.cpp @@ -0,0 +1,196 @@ +#include "LookedUpWordsActivity.h" + +#include + +#include + +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/LookupHistory.h" + +void LookedUpWordsActivity::taskTrampoline(void* param) { + auto* self = static_cast(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(words.size())) { + selectedIndex = std::max(0, static_cast(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(words.size())); + updateRequired = true; + }); + + buttonNavigator.onPrevious([this] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(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(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(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(); +} diff --git a/src/activities/reader/LookedUpWordsActivity.h b/src/activities/reader/LookedUpWordsActivity.h new file mode 100644 index 00000000..eeeae67c --- /dev/null +++ b/src/activities/reader/LookedUpWordsActivity.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#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& onBack, + const std::function& 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 onBack; + const std::function onSelectWord; + + std::vector 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(); +}; diff --git a/src/util/Dictionary.cpp b/src/util/Dictionary.cpp new file mode 100644 index 00000000..6e9952a9 --- /dev/null +++ b/src/util/Dictionary.cpp @@ -0,0 +1,328 @@ +#include "Dictionary.h" + +#include + +#include +#include +#include + +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(s1); + const auto* p2 = reinterpret_cast(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(c1) - static_cast(c2); + ++p1; + ++p2; + } + return static_cast(*p1) - static_cast(*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 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(word[start]))) { + start++; + } + + // Find last alphanumeric character + size_t end = word.size(); + while (end > start && !std::isalnum(static_cast(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(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(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(count * sizeof(uint32_t)); + if (cache.read(reinterpret_cast(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(sparseOffsets.size()); + uint32_t header[4] = {CACHE_MAGIC, idxFileSize, totalWords, count}; + + cache.write(reinterpret_cast(header), 16); + cache.write(reinterpret_cast(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& onProgress, + const std::function& 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(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(static_cast(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(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(&def[0]), size); + dict.close(); + + if (bytesRead < 0) return ""; + if (static_cast(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& 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(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(sparseOffsets.size()) - 1) { + maxEntries = static_cast(totalWords - static_cast(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(buf[0]) << 24) | (static_cast(buf[1]) << 16) | + (static_cast(buf[2]) << 8) | static_cast(buf[3]); + uint32_t dictSize = (static_cast(buf[4]) << 24) | (static_cast(buf[5]) << 16) | + (static_cast(buf[6]) << 8) | static_cast(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& onProgress, + const std::function& 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; +} diff --git a/src/util/Dictionary.h b/src/util/Dictionary.h new file mode 100644 index 00000000..7a7b1f63 --- /dev/null +++ b/src/util/Dictionary.h @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include +#include + +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& onProgress = nullptr, + const std::function& shouldCancel = nullptr); + static std::string cleanWord(const std::string& word); + + private: + static constexpr int SPARSE_INTERVAL = 512; + + static std::vector sparseOffsets; + static uint32_t totalWords; + static bool indexLoaded; + + static bool loadIndex(const std::function& onProgress, const std::function& shouldCancel); + static bool loadCachedIndex(); + static void saveCachedIndex(uint32_t idxFileSize); + static std::string searchIndex(const std::string& word, const std::function& shouldCancel); + static std::string readWord(FsFile& file); + static std::string readDefinition(uint32_t offset, uint32_t size); +}; diff --git a/src/util/LookupHistory.cpp b/src/util/LookupHistory.cpp new file mode 100644 index 00000000..af2f0b8e --- /dev/null +++ b/src/util/LookupHistory.cpp @@ -0,0 +1,88 @@ +#include "LookupHistory.h" + +#include + +#include + +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 LookupHistory::load(const std::string& cachePath) { + std::vector words; + FsFile f; + if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) { + return words; + } + + std::string line; + while (f.available() && static_cast(words.size()) < MAX_ENTRIES) { + char c; + if (f.read(reinterpret_cast(&c), 1) != 1) break; + if (c == '\n') { + if (!line.empty()) { + words.push_back(line); + line.clear(); + } + } else { + line += c; + } + } + if (!line.empty() && static_cast(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(w.c_str()), w.size()); + f.write(reinterpret_cast("\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(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(w.c_str()), w.size()); + f.write(reinterpret_cast("\n"), 1); + } + f.write(reinterpret_cast(word.c_str()), word.size()); + f.write(reinterpret_cast("\n"), 1); + f.close(); +} diff --git a/src/util/LookupHistory.h b/src/util/LookupHistory.h new file mode 100644 index 00000000..85a3f428 --- /dev/null +++ b/src/util/LookupHistory.h @@ -0,0 +1,15 @@ +#pragma once +#include +#include + +class LookupHistory { + public: + static std::vector 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; +};