diff --git a/.gitignore b/.gitignore index 0cc30a2..5dc4671 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ .idea .DS_Store .vscode +.cursor/ lib/EpdFont/fontsrc *.generated.h build -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +test/epubs/ \ No newline at end of file diff --git a/dict-en-en.zip b/dict-en-en.zip new file mode 100644 index 0000000..809d3cb Binary files /dev/null and b/dict-en-en.zip differ diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 2006194..37e76a2 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -31,6 +31,9 @@ class PageLine final : public PageElement { void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; bool serialize(FsFile& file) override; static std::unique_ptr deserialize(FsFile& file); + + // Getter for word selection support + const std::shared_ptr& getTextBlock() const { return block; } }; class Page { diff --git a/lib/Epub/Epub/blocks/TextBlock.h b/lib/Epub/Epub/blocks/TextBlock.h index e7993fe..a6f1b0f 100644 --- a/lib/Epub/Epub/blocks/TextBlock.h +++ b/lib/Epub/Epub/blocks/TextBlock.h @@ -48,6 +48,12 @@ class TextBlock final : public Block { Style getStyle() const { return style; } const BlockStyle& getBlockStyle() const { return blockStyle; } bool isEmpty() override { return words.empty(); } + + // Getters for word selection support + const std::list& getWords() const { return words; } + const std::list& getWordXPositions() const { return wordXpos; } + const std::list& getWordStyles() const { return wordStyles; } + size_t getWordCount() const { return words.size(); } void layout(GfxRenderer& renderer) override {}; // given a renderer works out where to break the words into lines void render(const GfxRenderer& renderer, int fontId, int x, int y) const; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index d9245d0..31787e2 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -510,7 +510,10 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char setOrientation(orig_orientation); } -void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { +void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) { + const Orientation orig_orientation = getOrientation(); + setOrientation(Orientation::Portrait); + const int screenWidth = getScreenWidth(); constexpr int buttonWidth = 40; // Width on screen (height when rotated) constexpr int buttonHeight = 80; // Height on screen (width when rotated) @@ -559,6 +562,8 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons drawTextRotated90CW(fontId, textX, textY, labels[i]); } } + + setOrientation(orig_orientation); } int GfxRenderer::getTextHeight(const int fontId) const { @@ -862,3 +867,4 @@ void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBo break; } } + diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index ae9f483..f105b56 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -86,7 +86,7 @@ class GfxRenderer { // UI Components void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); - void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; + void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn); private: // Helper for drawing rotated text (90 degrees clockwise, for side buttons) diff --git a/lib/StarDict/DictHtmlParser.cpp b/lib/StarDict/DictHtmlParser.cpp new file mode 100644 index 0000000..0e5c5dd --- /dev/null +++ b/lib/StarDict/DictHtmlParser.cpp @@ -0,0 +1,370 @@ +#include "DictHtmlParser.h" + +#include +#include + +#include +#include +#include + +std::string DictHtmlParser::decodeEntity(const std::string& html, size_t& i) { + const size_t start = i; // Position of '&' + const size_t remaining = html.length() - start; + + // Numeric entities: &#NNN; or &#xHHH; + if (remaining > 2 && html[start + 1] == '#') { + size_t numStart = start + 2; + bool isHex = false; + if (remaining > 3 && (html[numStart] == 'x' || html[numStart] == 'X')) { + isHex = true; + numStart++; + } + + size_t numEnd = numStart; + while (numEnd < html.length() && html[numEnd] != ';') { + const char c = html[numEnd]; + if (isHex) { + if (!std::isxdigit(static_cast(c))) break; + } else { + if (!std::isdigit(static_cast(c))) break; + } + numEnd++; + } + + if (numEnd > numStart && numEnd < html.length() && html[numEnd] == ';') { + const std::string numStr = html.substr(numStart, numEnd - numStart); + unsigned long codepoint = std::strtoul(numStr.c_str(), nullptr, isHex ? 16 : 10); + i = numEnd; // Will be incremented by caller's loop + + // Convert codepoint to UTF-8 + std::string utf8; + if (codepoint < 0x80) { + utf8 += static_cast(codepoint); + } else if (codepoint < 0x800) { + utf8 += static_cast(0xC0 | (codepoint >> 6)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x10000) { + utf8 += static_cast(0xE0 | (codepoint >> 12)); + utf8 += static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x110000) { + utf8 += static_cast(0xF0 | (codepoint >> 18)); + utf8 += static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + utf8 += static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } + return utf8; + } + } + + // Named entities - find the semicolon first + size_t semicolon = html.find(';', start + 1); + if (semicolon != std::string::npos && semicolon - start < 12) { + const std::string entity = html.substr(start, semicolon - start + 1); + + // Common named entities + struct EntityMapping { + const char* entity; + const char* replacement; + }; + static const EntityMapping entities[] = { + {" ", " "}, + {"<", "<"}, + {">", ">"}, + {"&", "&"}, + {""", "\""}, + {"'", "'"}, + {"—", "\xe2\x80\x94"}, // — + {"–", "\xe2\x80\x93"}, // – + {"…", "\xe2\x80\xa6"}, // … + {"’", "\xe2\x80\x99"}, // ' + {"‘", "\xe2\x80\x98"}, // ' + {"”", "\xe2\x80\x9d"}, // " + {"“", "\xe2\x80\x9c"}, // " + {"°", "\xc2\xb0"}, // ° + {"×", "\xc3\x97"}, // × + {"÷", "\xc3\xb7"}, // ÷ + {"±", "\xc2\xb1"}, // ± + {"½", "\xc2\xbd"}, // ½ + {"¼", "\xc2\xbc"}, // ¼ + {"¾", "\xc2\xbe"}, // ¾ + {"¢", "\xc2\xa2"}, // ¢ + {"£", "\xc2\xa3"}, // £ + {"€", "\xe2\x82\xac"}, // € + {"¥", "\xc2\xa5"}, // ¥ + {"©", "\xc2\xa9"}, // © + {"®", "\xc2\xae"}, // ® + {"™", "\xe2\x84\xa2"}, // ™ + {"•", "\xe2\x80\xa2"}, // • + {"·", "\xc2\xb7"}, // · + {"§", "\xc2\xa7"}, // § + {"¶", "\xc2\xb6"}, // ¶ + {"†", "\xe2\x80\xa0"}, // † + {"‡", "\xe2\x80\xa1"}, // ‡ + {"¡", "\xc2\xa1"}, // ¡ + {"¿", "\xc2\xbf"}, // ¿ + {"«", "\xc2\xab"}, // « + {"»", "\xc2\xbb"}, // » + {"‎", ""}, // Left-to-right mark (invisible) + {"‏", ""}, // Right-to-left mark (invisible) + {"­", ""}, // Soft hyphen + {" ", " "}, + {" ", " "}, + {" ", " "}, + {"‍", ""}, + {"‌", ""}, + }; + + for (const auto& mapping : entities) { + if (entity == mapping.entity) { + i = semicolon; // Will be incremented by caller's loop + return mapping.replacement; + } + } + } + + // Unknown entity - return just the ampersand + return "&"; +} + +std::string DictHtmlParser::extractTagName(const std::string& html, size_t start, bool& isClosing) { + isClosing = false; + size_t pos = start; + + // Skip whitespace after '<' + while (pos < html.length() && std::isspace(static_cast(html[pos]))) { + pos++; + } + + // Check for closing tag + if (pos < html.length() && html[pos] == '/') { + isClosing = true; + pos++; + } + + // Extract tag name (alphanumeric characters) + size_t nameStart = pos; + while (pos < html.length() && (std::isalnum(static_cast(html[pos])) || html[pos] == '!')) { + pos++; + } + + std::string tagName = html.substr(nameStart, pos - nameStart); + // Convert to lowercase + std::transform(tagName.begin(), tagName.end(), tagName.begin(), + [](unsigned char c) { return std::tolower(c); }); + return tagName; +} + +bool DictHtmlParser::isBlockTag(const std::string& tagName) { + return tagName == "p" || tagName == "div" || tagName == "br" || tagName == "hr" || tagName == "li" || + tagName == "ol" || tagName == "ul" || tagName == "dt" || tagName == "dd" || tagName == "html"; +} + +bool DictHtmlParser::isBoldTag(const std::string& tagName) { + return tagName == "b" || tagName == "strong"; +} + +bool DictHtmlParser::isItalicTag(const std::string& tagName) { + return tagName == "i" || tagName == "em"; +} + +bool DictHtmlParser::isUnderlineTag(const std::string& tagName) { + return tagName == "u" || tagName == "ins"; +} + +bool DictHtmlParser::isSuperscriptTag(const std::string& tagName) { return tagName == "sup"; } + +bool DictHtmlParser::isListItemTag(const std::string& tagName) { return tagName == "li"; } + +bool DictHtmlParser::isOrderedListTag(const std::string& tagName) { return tagName == "ol"; } + +void DictHtmlParser::parse(const std::string& html, int fontId, const GfxRenderer& renderer, uint16_t viewportWidth, + const std::function)>& onTextBlock) { + // Current paragraph being built + ParsedText currentParagraph(TextBlock::Style::LEFT_ALIGN, false, false); + + // State tracking + int boldDepth = 0; + int italicDepth = 0; + int underlineDepth = 0; + bool inSuperscript = false; + bool inTag = false; + + // List tracking + std::stack listCounters; // Stack for nested lists (0 = unordered, >0 = ordered counter) + + // Current word being accumulated + std::string currentWord; + bool lastWasSpace = true; // Start true to skip leading spaces + + // Helper to flush current word to paragraph + auto flushWord = [&]() { + if (currentWord.empty()) return; + + // Determine font style + EpdFontFamily::Style fontStyle = EpdFontFamily::REGULAR; + if (boldDepth > 0 && italicDepth > 0) { + fontStyle = EpdFontFamily::BOLD_ITALIC; + } else if (boldDepth > 0) { + fontStyle = EpdFontFamily::BOLD; + } else if (italicDepth > 0) { + fontStyle = EpdFontFamily::ITALIC; + } + + currentParagraph.addWord(currentWord, fontStyle, underlineDepth > 0); + currentWord.clear(); + lastWasSpace = false; + }; + + // Helper to flush current paragraph (create TextBlocks) + auto flushParagraph = [&]() { + flushWord(); + if (!currentParagraph.isEmpty()) { + currentParagraph.layoutAndExtractLines(renderer, fontId, viewportWidth, onTextBlock); + currentParagraph = ParsedText(TextBlock::Style::LEFT_ALIGN, false, false); + } + lastWasSpace = true; + }; + + // Parse the HTML + for (size_t i = 0; i < html.length(); i++) { + const char c = html[i]; + + if (c == '<') { + // Start of tag - flush current word first + flushWord(); + + // Find end of tag + size_t tagEnd = html.find('>', i); + if (tagEnd == std::string::npos) { + // Malformed HTML - treat rest as text + currentWord += c; + continue; + } + + // Extract tag name + bool isClosing = false; + std::string tagName = extractTagName(html, i + 1, isClosing); + + // Handle different tag types + if (isBoldTag(tagName)) { + if (isClosing) { + boldDepth = std::max(0, boldDepth - 1); + } else { + boldDepth++; + } + } else if (isItalicTag(tagName)) { + if (isClosing) { + italicDepth = std::max(0, italicDepth - 1); + } else { + italicDepth++; + } + } else if (isUnderlineTag(tagName)) { + if (isClosing) { + underlineDepth = std::max(0, underlineDepth - 1); + } else { + underlineDepth++; + } + } else if (isSuperscriptTag(tagName)) { + if (isClosing) { + inSuperscript = false; + } else { + inSuperscript = true; + // Add caret prefix for superscript + currentWord += '^'; + } + } else if (isOrderedListTag(tagName)) { + if (isClosing) { + if (!listCounters.empty()) { + listCounters.pop(); + } + } else { + // Check if it's an unordered list style + std::string tagContent = html.substr(i, tagEnd - i); + if (tagContent.find("list-style-type:lower-alpha") != std::string::npos) { + listCounters.push(-1); // -1 = alphabetic + } else { + listCounters.push(1); // Start at 1 for ordered + } + } + } else if (tagName == "ul") { + if (isClosing) { + if (!listCounters.empty()) { + listCounters.pop(); + } + } else { + listCounters.push(0); // 0 = unordered (bullet) + } + } else if (isListItemTag(tagName) && !isClosing) { + // Start of list item - flush paragraph and add bullet/number + flushParagraph(); + + std::string prefix; + if (!listCounters.empty()) { + int counter = listCounters.top(); + if (counter == 0) { + // Unordered - bullet point + prefix = "\xe2\x80\xa2 "; // • bullet + } else if (counter == -1) { + // Alphabetic - not fully supported, just use bullet + prefix = " "; + } else { + // Ordered - number + char numBuf[8]; + snprintf(numBuf, sizeof(numBuf), "%d. ", counter); + prefix = numBuf; + listCounters.pop(); + listCounters.push(counter + 1); // Increment for next item + } + } else { + // No list context - just indent + prefix = "\xe2\x80\xa2 "; // • bullet + } + + // Add prefix as a word (em-space for indent + prefix) + currentParagraph.addWord("\xe2\x80\x83" + prefix, EpdFontFamily::REGULAR, false); + lastWasSpace = true; + } else if (isBlockTag(tagName)) { + // Block element - flush paragraph + flushParagraph(); + + // Special handling for which separates dictionary entries + if (tagName == "html" && isClosing) { + // Add extra spacing between entries + flushParagraph(); + } + } + + // Skip to end of tag + i = tagEnd; + } else if (c == '&') { + // HTML entity + std::string decoded = decodeEntity(html, i); + if (!decoded.empty()) { + if (decoded == " ") { + // Space entity - treat as space + if (!lastWasSpace) { + flushWord(); + lastWasSpace = true; + } + } else { + currentWord += decoded; + lastWasSpace = false; + } + } + } else if (std::isspace(static_cast(c))) { + // Whitespace - flush word and collapse + if (!lastWasSpace) { + flushWord(); + lastWasSpace = true; + } + } else { + // Regular character + currentWord += c; + lastWasSpace = false; + } + } + + // Flush any remaining content + flushParagraph(); +} diff --git a/lib/StarDict/DictHtmlParser.h b/lib/StarDict/DictHtmlParser.h new file mode 100644 index 0000000..6eb501c --- /dev/null +++ b/lib/StarDict/DictHtmlParser.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include +#include +#include + +class GfxRenderer; + +/** + * DictHtmlParser parses HTML dictionary definitions into ParsedText. + * + * Supports: + * - Bold: , + * - Italic: , + * - Underline: , + * - Lists:
    ,
  1. with numbering/bullets + * - Block elements:

    ,
    ,


    , (entry separator) + * - HTML entities: numeric (&#NNN;, &#xHHH;) and named (&, etc.) + * - Superscript: rendered as ^text + */ +class DictHtmlParser { + public: + /** + * Parse HTML definition and populate ParsedText with styled words. + * Each paragraph/block creates a separate ParsedText via the callback. + * + * @param html The HTML definition text + * @param fontId Font ID for text width calculations + * @param renderer Reference to renderer for layout + * @param onParagraph Callback invoked for each paragraph/block of text + */ + static void parse(const std::string& html, int fontId, const GfxRenderer& renderer, uint16_t viewportWidth, + const std::function)>& onTextBlock); + + private: + // Decode HTML entity at position i (starting with '&') + static std::string decodeEntity(const std::string& html, size_t& i); + + // Extract tag name from position (after '<') + static std::string extractTagName(const std::string& html, size_t start, bool& isClosing); + + // Check if tag is a block-level element + static bool isBlockTag(const std::string& tagName); + + // Check if tag starts/ends bold + static bool isBoldTag(const std::string& tagName); + + // Check if tag starts/ends italic + static bool isItalicTag(const std::string& tagName); + + // Check if tag starts/ends underline + static bool isUnderlineTag(const std::string& tagName); + + // Check if tag is superscript + static bool isSuperscriptTag(const std::string& tagName); + + // Check if tag is list item + static bool isListItemTag(const std::string& tagName); + + // Check if tag starts ordered list + static bool isOrderedListTag(const std::string& tagName); +}; diff --git a/lib/StarDict/StarDict.cpp b/lib/StarDict/StarDict.cpp new file mode 100644 index 0000000..510bedd --- /dev/null +++ b/lib/StarDict/StarDict.cpp @@ -0,0 +1,759 @@ +#include "StarDict.h" + +#include +#include +#include + +#include +#include + +#include "DictPrefixIndex.generated.h" + +StarDict::StarDict(const std::string& basePath) : basePath(basePath) {} + +StarDict::~StarDict() { + if (dzInfo.chunkSizes) { + free(dzInfo.chunkSizes); + dzInfo.chunkSizes = nullptr; + } +} + +uint32_t StarDict::readBE32(const uint8_t* data) { + return (static_cast(data[0]) << 24) | (static_cast(data[1]) << 16) | + (static_cast(data[2]) << 8) | static_cast(data[3]); +} + +bool StarDict::loadInfo() { + const std::string ifoPath = basePath + ".ifo"; + FsFile file; + if (!SdMan.openFileForRead("DICT", ifoPath, file)) { + Serial.printf("[%lu] [DICT] Failed to open .ifo file: %s\n", millis(), ifoPath.c_str()); + return false; + } + + char buffer[256]; + while (file.available()) { + const int len = file.fgets(buffer, sizeof(buffer)); + if (len <= 0) break; + + // Remove newline + char* newline = strchr(buffer, '\n'); + if (newline) *newline = '\0'; + newline = strchr(buffer, '\r'); + if (newline) *newline = '\0'; + + // Parse key=value + char* eq = strchr(buffer, '='); + if (!eq) continue; + + *eq = '\0'; + const char* key = buffer; + const char* value = eq + 1; + + if (strcmp(key, "bookname") == 0) { + info.bookname = value; + } else if (strcmp(key, "wordcount") == 0) { + info.wordcount = strtoul(value, nullptr, 10); + } else if (strcmp(key, "idxfilesize") == 0) { + info.idxfilesize = strtoul(value, nullptr, 10); + } else if (strcmp(key, "sametypesequence") == 0) { + info.sametypesequence = value[0]; + } else if (strcmp(key, "synwordcount") == 0) { + info.synwordcount = strtoul(value, nullptr, 10); + } + } + + file.close(); + info.loaded = true; + + Serial.printf("[%lu] [DICT] Loaded dictionary: %s (%u words)\n", millis(), info.bookname.c_str(), info.wordcount); + return true; +} + +bool StarDict::loadDictzipHeader() { + if (dzInfo.loaded) return true; + + const std::string dzPath = basePath + ".dict.dz"; + FsFile file; + if (!SdMan.openFileForRead("DICT", dzPath, file)) { + Serial.printf("[%lu] [DICT] Failed to open .dict.dz file\n", millis()); + return false; + } + + // Read gzip header + uint8_t header[10]; + if (file.read(header, 10) != 10) { + file.close(); + return false; + } + + // Verify gzip magic number + if (header[0] != 0x1f || header[1] != 0x8b) { + Serial.printf("[%lu] [DICT] Not a valid gzip file\n", millis()); + file.close(); + return false; + } + + // Check for extra field flag (bit 2) + const uint8_t flags = header[3]; + if (!(flags & 0x04)) { + Serial.printf("[%lu] [DICT] No extra field - not a dictzip file\n", millis()); + file.close(); + return false; + } + + // Read extra field length + uint8_t xlenBuf[2]; + if (file.read(xlenBuf, 2) != 2) { + file.close(); + return false; + } + const uint16_t xlen = xlenBuf[0] | (xlenBuf[1] << 8); + + // Read extra field + auto* extraField = static_cast(malloc(xlen)); + if (!extraField) { + file.close(); + return false; + } + + if (file.read(extraField, xlen) != xlen) { + free(extraField); + file.close(); + return false; + } + + // Parse dictzip subfield (SI1='R', SI2='A') + bool foundDictzip = false; + uint16_t pos = 0; + while (pos + 4 <= xlen) { + const uint8_t si1 = extraField[pos]; + const uint8_t si2 = extraField[pos + 1]; + const uint16_t slen = extraField[pos + 2] | (extraField[pos + 3] << 8); + + if (si1 == 'R' && si2 == 'A' && pos + 4 + slen <= xlen) { + // Dictzip subfield found + // Format: ver(2) + chlen(2) + count(2) + sizes[count](2 each) + const uint8_t* data = &extraField[pos + 4]; + // uint16_t version = data[0] | (data[1] << 8); // Usually 1 + dzInfo.chunkLength = data[2] | (data[3] << 8); + dzInfo.chunkCount = data[4] | (data[5] << 8); + + dzInfo.chunkSizes = static_cast(malloc(dzInfo.chunkCount * sizeof(uint16_t))); + if (!dzInfo.chunkSizes) { + free(extraField); + file.close(); + return false; + } + + for (uint16_t i = 0; i < dzInfo.chunkCount; i++) { + dzInfo.chunkSizes[i] = data[6 + i * 2] | (data[7 + i * 2] << 8); + } + + foundDictzip = true; + break; + } + + pos += 4 + slen; + } + + free(extraField); + + if (!foundDictzip) { + Serial.printf("[%lu] [DICT] Dictzip subfield not found\n", millis()); + file.close(); + return false; + } + + // Calculate header size (10 + 2 + xlen + optional fields) + dzInfo.headerSize = 10 + 2 + xlen; + + // Skip FNAME if present (bit 3) + if (flags & 0x08) { + file.seek(dzInfo.headerSize); + while (file.available()) { + uint8_t c; + file.read(&c, 1); + dzInfo.headerSize++; + if (c == 0) break; + } + } + + // Skip FCOMMENT if present (bit 4) + if (flags & 0x10) { + file.seek(dzInfo.headerSize); + while (file.available()) { + uint8_t c; + file.read(&c, 1); + dzInfo.headerSize++; + if (c == 0) break; + } + } + + // Skip FHCRC if present (bit 1) + if (flags & 0x02) { + dzInfo.headerSize += 2; + } + + file.close(); + dzInfo.loaded = true; + + Serial.printf("[%lu] [DICT] Dictzip: %u chunks of %u bytes, header size %u\n", millis(), dzInfo.chunkCount, + dzInfo.chunkLength, dzInfo.headerSize); + return true; +} + +bool StarDict::begin() { + if (!loadInfo()) return false; + if (!loadDictzipHeader()) return false; + return true; +} + +bool StarDict::readWordAtPosition(FsFile& idxFile, uint32_t& position, std::string& word, uint32_t& dictOffset, + uint32_t& dictSize) { + idxFile.seek(position); + + // Read null-terminated word + word.clear(); + char c; + while (idxFile.read(&c, 1) == 1) { + if (c == '\0') break; + word += c; + if (word.length() > 256) { + // Safety limit + return false; + } + } + + if (word.empty()) return false; + + // Read 4-byte big-endian offset + uint8_t buf[8]; + if (idxFile.read(buf, 8) != 8) return false; + + dictOffset = readBE32(buf); + dictSize = readBE32(buf + 4); + + position = idxFile.position(); + return true; +} + +bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string& definition) { + if (!dzInfo.loaded) return false; + + const std::string dzPath = basePath + ".dict.dz"; + FsFile file; + if (!SdMan.openFileForRead("DICT", dzPath, file)) { + return false; + } + + // Calculate which chunk(s) we need + const uint32_t startChunk = offset / dzInfo.chunkLength; + const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength; + const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength; + + if (endChunk >= dzInfo.chunkCount) { + file.close(); + return false; + } + + // Calculate file offset for start chunk + uint32_t fileOffset = dzInfo.headerSize; + for (uint32_t i = 0; i < startChunk; i++) { + fileOffset += dzInfo.chunkSizes[i]; + } + + // Allocate buffers + const uint32_t maxCompressedSize = 65536; // Max compressed chunk size + auto* compressedBuf = static_cast(malloc(maxCompressedSize)); + auto* decompressedBuf = static_cast(malloc(dzInfo.chunkLength)); + if (!compressedBuf || !decompressedBuf) { + free(compressedBuf); + free(decompressedBuf); + file.close(); + return false; + } + + definition.clear(); + definition.reserve(size); + + // Process each needed chunk + for (uint32_t chunk = startChunk; chunk <= endChunk; chunk++) { + const uint16_t compressedSize = dzInfo.chunkSizes[chunk]; + + // Seek and read compressed data + file.seek(fileOffset); + if (file.read(compressedBuf, compressedSize) != compressedSize) { + free(compressedBuf); + free(decompressedBuf); + file.close(); + return false; + } + + // Decompress using raw inflate (no zlib header) + auto* inflator = static_cast(malloc(sizeof(tinfl_decompressor))); + if (!inflator) { + free(compressedBuf); + free(decompressedBuf); + file.close(); + return false; + } + tinfl_init(inflator); + + size_t inBytes = compressedSize; + size_t outBytes = dzInfo.chunkLength; + const tinfl_status status = + tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes, + TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | TINFL_FLAG_PARSE_ZLIB_HEADER); + + free(inflator); + + if (status != TINFL_STATUS_DONE && status != TINFL_STATUS_HAS_MORE_OUTPUT) { + // Try without zlib header flag + inflator = static_cast(malloc(sizeof(tinfl_decompressor))); + if (inflator) { + tinfl_init(inflator); + inBytes = compressedSize; + outBytes = dzInfo.chunkLength; + tinfl_decompress(inflator, compressedBuf, &inBytes, decompressedBuf, decompressedBuf, &outBytes, + TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF); + free(inflator); + } + } + + // Extract the portion we need from this chunk + uint32_t copyStart = 0; + uint32_t copyEnd = outBytes; + + if (chunk == startChunk) { + copyStart = startOffsetInChunk; + } + if (chunk == endChunk) { + const uint32_t endOffsetInChunk = (offset + size) - (endChunk * dzInfo.chunkLength); + if (endOffsetInChunk < copyEnd) { + copyEnd = endOffsetInChunk; + } + } + + if (copyEnd > copyStart) { + definition.append(reinterpret_cast(decompressedBuf + copyStart), copyEnd - copyStart); + } + + fileOffset += compressedSize; + } + + free(compressedBuf); + free(decompressedBuf); + file.close(); + + return true; +} + +// StarDict comparison function: case-insensitive first, then case-sensitive as tiebreaker +int StarDict::stardictStrcmp(const std::string& a, const std::string& b) { + // First: case-insensitive comparison (like g_ascii_strcasecmp) + size_t i = 0; + while (i < a.length() && i < b.length()) { + const int ca = std::tolower(static_cast(a[i])); + const int cb = std::tolower(static_cast(b[i])); + if (ca != cb) return ca - cb; + i++; + } + if (a.length() != b.length()) { + return static_cast(a.length()) - static_cast(b.length()); + } + // If case-insensitive equal, use case-sensitive as tiebreaker + return a.compare(b); +} + +std::string StarDict::normalizeWord(const std::string& word) { + std::string result; + result.reserve(word.length()); + + // Trim leading whitespace + size_t start = 0; + while (start < word.length() && std::isspace(static_cast(word[start]))) { + start++; + } + + // Trim trailing whitespace + size_t end = word.length(); + while (end > start && std::isspace(static_cast(word[end - 1]))) { + end--; + } + + // Convert to lowercase + for (size_t i = start; i < end; i++) { + result += static_cast(std::tolower(static_cast(word[i]))); + } + + return result; +} + +StarDict::LookupResult StarDict::lookup(const std::string& word) { + LookupResult result; + result.word = word; + + if (!info.loaded) { + return result; + } + + const std::string normalizedSearch = normalizeWord(word); + if (normalizedSearch.empty()) { + return result; + } + + // First try .idx (main entries) - use prefix jump table for fast lookup + const std::string idxPath = basePath + ".idx"; + FsFile idxFile; + if (!SdMan.openFileForRead("DICT", idxPath, idxFile)) { + Serial.printf("[%lu] [DICT] Failed to open index file\n", millis()); + return result; + } + + // Jump to the relevant section using prefix index (if word has 2+ alpha chars) + uint32_t position = 0; + if (normalizedSearch.length() >= 2 && DictPrefixIndex::isAlpha(normalizedSearch[0]) && + DictPrefixIndex::isAlpha(normalizedSearch[1])) { + const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]); + position = DictPrefixIndex::dictPrefixOffsets[prefixIdx]; + } + bool found = false; + + while (position < info.idxfilesize) { + std::string currentWord; + uint32_t dictOffset, dictSize; + + if (!readWordAtPosition(idxFile, position, currentWord, dictOffset, dictSize)) { + break; + } + + // Use stardictStrcmp for case-insensitive matching + const int cmp = stardictStrcmp(normalizedSearch, currentWord); + + if (cmp == 0) { + std::string definition; + if (decompressDefinition(dictOffset, dictSize, definition)) { + if (!found) { + result.word = currentWord; + result.definition = definition; + result.found = true; + found = true; + } else { + result.definition += "" + definition; + } + } + // Continue scanning for additional matches (same word, different case) + } else if (cmp < 0) { + // Passed where target would be (file is sorted) + break; + } + } + + idxFile.close(); + + // If not found in main index, try synonym file with prefix jump + if (!found && info.synwordcount > 0) { + const std::string synPath = basePath + ".syn"; + FsFile synFile; + if (SdMan.openFileForRead("DICT", synPath, synFile)) { + const uint32_t synFileSize = synFile.size(); + + // Jump to the relevant section using prefix index (if word has 2+ alpha chars) + uint32_t synPosition = 0; + if (normalizedSearch.length() >= 2 && DictPrefixIndex::isAlpha(normalizedSearch[0]) && + DictPrefixIndex::isAlpha(normalizedSearch[1])) { + const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]); + synPosition = DictPrefixIndex::synPrefixOffsets[prefixIdx]; + synFile.seek(synPosition); + } + + while (synFile.position() < synFileSize) { + // Read synonym word (null-terminated) + std::string synWord; + char c; + while (synFile.read(&c, 1) == 1 && c != '\0') { + synWord += c; + } + + // Read 4-byte big-endian index + uint8_t idxBytes[4]; + if (synFile.read(idxBytes, 4) != 4) break; + const uint32_t mainIdx = readBE32(idxBytes); + + // Use stardictStrcmp for case-insensitive comparison + const int cmp = stardictStrcmp(normalizedSearch, synWord); + + if (cmp == 0) { + // Found synonym - look up the main entry by index + FsFile idxFile2; + if (SdMan.openFileForRead("DICT", idxPath, idxFile2)) { + uint32_t pos = 0; + uint32_t entryNum = 0; + while (entryNum < mainIdx && pos < info.idxfilesize) { + std::string w; + uint32_t off, sz; + if (!readWordAtPosition(idxFile2, pos, w, off, sz)) break; + entryNum++; + } + // Now read the target entry + if (entryNum == mainIdx) { + std::string mainWord; + uint32_t dictOffset, dictSize; + if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) { + std::string definition; + if (decompressDefinition(dictOffset, dictSize, definition)) { + result.word = synWord; + result.definition = definition; + result.found = true; + found = true; + } + } + } + idxFile2.close(); + } + break; // Found a match, stop searching + } else if (cmp < 0) { + // Passed where it would be (file is sorted) + break; + } + } + synFile.close(); + } + } + + return result; +} + +// Helper to decode a single HTML entity starting at position i (after the '&') +// Returns the decoded string and advances i past the entity (including ';') +static std::string decodeHtmlEntity(const std::string& html, size_t& i) { + const size_t start = i; // Position of '&' + const size_t remaining = html.length() - start; + + // Numeric entities: &#NNN; or &#xHHH; + if (remaining > 2 && html[start + 1] == '#') { + size_t numStart = start + 2; + bool isHex = false; + if (remaining > 3 && (html[numStart] == 'x' || html[numStart] == 'X')) { + isHex = true; + numStart++; + } + + size_t numEnd = numStart; + while (numEnd < html.length() && html[numEnd] != ';') { + const char c = html[numEnd]; + if (isHex) { + if (!std::isxdigit(static_cast(c))) break; + } else { + if (!std::isdigit(static_cast(c))) break; + } + numEnd++; + } + + if (numEnd > numStart && numEnd < html.length() && html[numEnd] == ';') { + const std::string numStr = html.substr(numStart, numEnd - numStart); + unsigned long codepoint = std::strtoul(numStr.c_str(), nullptr, isHex ? 16 : 10); + i = numEnd; // Will be incremented by caller's loop + + // Convert codepoint to UTF-8 + std::string utf8; + if (codepoint < 0x80) { + utf8 += static_cast(codepoint); + } else if (codepoint < 0x800) { + utf8 += static_cast(0xC0 | (codepoint >> 6)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x10000) { + utf8 += static_cast(0xE0 | (codepoint >> 12)); + utf8 += static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } else if (codepoint < 0x110000) { + utf8 += static_cast(0xF0 | (codepoint >> 18)); + utf8 += static_cast(0x80 | ((codepoint >> 12) & 0x3F)); + utf8 += static_cast(0x80 | ((codepoint >> 6) & 0x3F)); + utf8 += static_cast(0x80 | (codepoint & 0x3F)); + } + return utf8; + } + } + + // Named entities - find the semicolon first + size_t semicolon = html.find(';', start + 1); + if (semicolon != std::string::npos && semicolon - start < 12) { + const std::string entity = html.substr(start, semicolon - start + 1); + + // Common named entities + struct EntityMapping { + const char* entity; + const char* replacement; + }; + static const EntityMapping entities[] = { + {" ", " "}, {"<", "<"}, {">", ">"}, + {"&", "&"}, {""", "\""}, {"'", "'"}, + {"—", "\xe2\x80\x94"}, // — + {"–", "\xe2\x80\x93"}, // – + {"…", "\xe2\x80\xa6"}, // … + {"’", "\xe2\x80\x99"}, // ' + {"‘", "\xe2\x80\x98"}, // ' + {"”", "\xe2\x80\x9d"}, // " + {"“", "\xe2\x80\x9c"}, // " + {"°", "\xc2\xb0"}, // ° + {"×", "\xc3\x97"}, // × + {"÷", "\xc3\xb7"}, // ÷ + {"±", "\xc2\xb1"}, // ± + {"½", "\xc2\xbd"}, // ½ + {"¼", "\xc2\xbc"}, // ¼ + {"¾", "\xc2\xbe"}, // ¾ + {"¢", "\xc2\xa2"}, // ¢ + {"£", "\xc2\xa3"}, // £ + {"€", "\xe2\x82\xac"}, // € + {"¥", "\xc2\xa5"}, // ¥ + {"©", "\xc2\xa9"}, // © + {"®", "\xc2\xae"}, // ® + {"™", "\xe2\x84\xa2"}, // ™ + {"•", "\xe2\x80\xa2"}, // • + {"·", "\xc2\xb7"}, // · + {"§", "\xc2\xa7"}, // § + {"¶", "\xc2\xb6"}, // ¶ + {"†", "\xe2\x80\xa0"}, // † + {"‡", "\xe2\x80\xa1"}, // ‡ + {"¡", "\xc2\xa1"}, // ¡ + {"¿", "\xc2\xbf"}, // ¿ + {"«", "\xc2\xab"}, // « + {"»", "\xc2\xbb"}, // » + {"­", ""}, + {" ", " "}, + {" ", " "}, + {" ", " "}, + {"‍", ""}, + {"‌", ""}, + }; + + for (const auto& mapping : entities) { + if (entity == mapping.entity) { + i = semicolon; // Will be incremented by caller's loop + return mapping.replacement; + } + } + } + + // Unknown entity - return just the ampersand and let the rest be processed normally + return "&"; +} + +// Helper to check if a tag is a block-level element that needs line breaks +static bool isBlockTag(const std::string& tag, bool isClosing) { + // Normalize to lowercase for comparison + std::string lowerTag = tag; + for (char& c : lowerTag) { + c = std::tolower(static_cast(c)); + } + + // Block-level tags that should have line breaks + if (lowerTag == "p" || lowerTag == "div" || lowerTag == "br" || lowerTag == "hr" || lowerTag == "li" || + lowerTag == "dt" || lowerTag == "dd" || lowerTag == "tr" || lowerTag == "h1" || lowerTag == "h2" || + lowerTag == "h3" || lowerTag == "h4" || lowerTag == "h5" || lowerTag == "h6" || lowerTag == "blockquote" || + lowerTag == "pre" || lowerTag == "ol" || lowerTag == "ul") { + return true; + } + return false; +} + +std::string StarDict::stripHtml(const std::string& html) { + std::string result; + result.reserve(html.length()); + + bool inTag = false; + bool lastWasSpace = false; + bool lastWasNewline = false; + + for (size_t i = 0; i < html.length(); i++) { + const char c = html[i]; + + if (c == '<') { + // Parse the tag name + size_t tagStart = i + 1; + bool isClosing = false; + + // Skip whitespace after < + while (tagStart < html.length() && std::isspace(static_cast(html[tagStart]))) { + tagStart++; + } + + // Check for closing tag + if (tagStart < html.length() && html[tagStart] == '/') { + isClosing = true; + tagStart++; + } + + // Extract tag name + size_t tagEnd = tagStart; + while (tagEnd < html.length() && !std::isspace(static_cast(html[tagEnd])) && + html[tagEnd] != '>' && html[tagEnd] != '/') { + tagEnd++; + } + + const std::string tagName = html.substr(tagStart, tagEnd - tagStart); + + // Check if this is a block-level element + if (isBlockTag(tagName, isClosing)) { + // Add line break for block elements + if (!result.empty() && !lastWasNewline) { + result += '\n'; + lastWasNewline = true; + lastWasSpace = true; + } + } + + inTag = true; + } else if (c == '>') { + inTag = false; + } else if (!inTag) { + // Handle HTML entities + if (c == '&') { + const std::string decoded = decodeHtmlEntity(html, i); + if (!decoded.empty()) { + // Check if decoded content is whitespace + bool allSpace = true; + for (const char dc : decoded) { + if (!std::isspace(static_cast(dc))) { + allSpace = false; + break; + } + } + + if (allSpace) { + if (!lastWasSpace) { + result += ' '; + lastWasSpace = true; + } + } else { + result += decoded; + lastWasSpace = false; + lastWasNewline = false; + } + } + continue; + } + + // Collapse whitespace + if (std::isspace(static_cast(c))) { + if (!lastWasSpace) { + result += ' '; + lastWasSpace = true; + } + } else { + result += c; + lastWasSpace = false; + lastWasNewline = false; + } + } + } + + // Trim trailing whitespace + while (!result.empty() && std::isspace(static_cast(result.back()))) { + result.pop_back(); + } + + return result; +} diff --git a/lib/StarDict/StarDict.h b/lib/StarDict/StarDict.h new file mode 100644 index 0000000..4668c90 --- /dev/null +++ b/lib/StarDict/StarDict.h @@ -0,0 +1,81 @@ +#pragma once + +#include + +#include +#include + +// StarDict dictionary lookup library +// Supports .ifo/.idx/.dict.dz format with linear scan lookup +class StarDict { + public: + struct DictInfo { + std::string bookname; + uint32_t wordcount = 0; + uint32_t idxfilesize = 0; + char sametypesequence = '\0'; // 'h' for HTML, 'm' for plain text, etc. + uint32_t synwordcount = 0; + bool loaded = false; + }; + + struct LookupResult { + std::string word; + std::string definition; + bool found = false; + }; + + private: + std::string basePath; // Path without extension (e.g., "/dictionaries/dict-data") + DictInfo info; + + // Dictzip chunk info for random access decompression + struct DictzipInfo { + uint32_t chunkLength = 0; // Uncompressed chunk size (usually 58315) + uint16_t chunkCount = 0; + uint32_t headerSize = 0; // Total header size to skip + uint16_t* chunkSizes = nullptr; // Array of compressed chunk sizes + bool loaded = false; + }; + DictzipInfo dzInfo; + + // Parse .ifo file + bool loadInfo(); + + // Load dictzip header for random access + bool loadDictzipHeader(); + + // Read word at given index file position, returns word and advances position + bool readWordAtPosition(FsFile& idxFile, uint32_t& position, std::string& word, uint32_t& dictOffset, + uint32_t& dictSize); + + // Decompress a portion of the .dict.dz file + bool decompressDefinition(uint32_t offset, uint32_t size, std::string& definition); + + // Convert 4-byte big-endian to uint32 + static uint32_t readBE32(const uint8_t* data); + + public: + explicit StarDict(const std::string& basePath); + ~StarDict(); + + // Initialize dictionary (loads .ifo) + bool begin(); + + // Get dictionary info + const DictInfo& getInfo() const { return info; } + + // Look up a word (case-insensitive) + LookupResult lookup(const std::string& word); + + // Check if dictionary is ready + bool isReady() const { return info.loaded; } + + // Strip HTML tags from definition for plain text display + static std::string stripHtml(const std::string& html); + + // Normalize word for comparison (lowercase, trim) + static std::string normalizeWord(const std::string& word); + + // StarDict comparison (case-insensitive first, then case-sensitive tiebreaker) + static int stardictStrcmp(const std::string& a, const std::string& b); +}; diff --git a/scripts/generate_dict_index.py b/scripts/generate_dict_index.py new file mode 100755 index 0000000..d272c48 --- /dev/null +++ b/scripts/generate_dict_index.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Generate prefix jump tables for StarDict dictionary lookup optimization. + +This script parses StarDict .idx and .syn files and generates a C++ header +with pre-computed byte offsets for two-letter prefixes (aa-zz). This enables +near-instant lookup by jumping directly to the relevant section of the index. + +Usage: + ./scripts/generate_dict_index.py --idx path/to/dict.idx --syn path/to/dict.syn --output lib/StarDict/DictPrefixIndex.generated.h + +Or extract from a zip file: + ./scripts/generate_dict_index.py --zip dict-en-en.zip --output lib/StarDict/DictPrefixIndex.generated.h +""" + +from __future__ import annotations + +import argparse +import pathlib +import struct +import zipfile +from typing import BinaryIO + + +def prefix_to_index(c1: str, c2: str) -> int: + """Convert two-letter prefix to index (0-675). + + 'aa' -> 0, 'ab' -> 1, ... 'zz' -> 675 + """ + return (ord(c1.lower()) - ord('a')) * 26 + (ord(c2.lower()) - ord('a')) + + +def index_to_prefix(idx: int) -> str: + """Convert index back to two-letter prefix for debugging.""" + c1 = chr(ord('a') + idx // 26) + c2 = chr(ord('a') + idx % 26) + return c1 + c2 + + +def is_alpha(c: str) -> bool: + """Check if character is a-z or A-Z.""" + return ('a' <= c <= 'z') or ('A' <= c <= 'Z') + + +def read_null_terminated_string(f: BinaryIO) -> tuple[str, int]: + """Read a null-terminated string from file. + + Returns (string, bytes_read including null terminator). + """ + chars = [] + bytes_read = 0 + while True: + b = f.read(1) + if not b: + break + bytes_read += 1 + if b == b'\x00': + break + chars.append(b.decode('utf-8', errors='replace')) + return ''.join(chars), bytes_read + + +def parse_idx_file(f: BinaryIO, file_size: int) -> dict[int, int]: + """Parse StarDict .idx file and build prefix -> offset mapping. + + The .idx file format is: + [word\0][offset:4 bytes BE][size:4 bytes BE] + ...repeated for each word... + + Returns dict mapping prefix index (0-675) to first byte offset for that prefix. + """ + prefix_offsets: dict[int, int] = {} + current_position = 0 + words_processed = 0 + + while current_position < file_size: + entry_start = current_position + + # Read the word + word, word_bytes = read_null_terminated_string(f) + if not word: + break + + current_position += word_bytes + + # Read 8 bytes (offset + size, both big-endian) + data = f.read(8) + if len(data) != 8: + break + current_position += 8 + + # Extract prefix if word has at least 2 alphabetic characters + if len(word) >= 2 and is_alpha(word[0]) and is_alpha(word[1]): + prefix_idx = prefix_to_index(word[0], word[1]) + + # Only record the first occurrence of each prefix + if prefix_idx not in prefix_offsets: + prefix_offsets[prefix_idx] = entry_start + + words_processed += 1 + if words_processed % 100000 == 0: + print(f" Processed {words_processed} words...") + + print(f" Total words processed: {words_processed}") + print(f" Unique prefixes found: {len(prefix_offsets)}") + + return prefix_offsets + + +def parse_syn_file(f: BinaryIO, file_size: int) -> dict[int, int]: + """Parse StarDict .syn file and build prefix -> offset mapping. + + The .syn file format is: + [synonym_word\0][main_entry_index:4 bytes BE] + ...repeated for each synonym... + + Returns dict mapping prefix index (0-675) to first byte offset for that prefix. + """ + prefix_offsets: dict[int, int] = {} + current_position = 0 + synonyms_processed = 0 + + while current_position < file_size: + entry_start = current_position + + # Read the synonym word + word, word_bytes = read_null_terminated_string(f) + if not word: + break + + current_position += word_bytes + + # Read 4 bytes (index to main entry, big-endian) + data = f.read(4) + if len(data) != 4: + break + current_position += 4 + + # Extract prefix if word has at least 2 alphabetic characters + if len(word) >= 2 and is_alpha(word[0]) and is_alpha(word[1]): + prefix_idx = prefix_to_index(word[0], word[1]) + + # Only record the first occurrence of each prefix + if prefix_idx not in prefix_offsets: + prefix_offsets[prefix_idx] = entry_start + + synonyms_processed += 1 + if synonyms_processed % 100000 == 0: + print(f" Processed {synonyms_processed} synonyms...") + + print(f" Total synonyms processed: {synonyms_processed}") + print(f" Unique prefixes found: {len(prefix_offsets)}") + + return prefix_offsets + + +def fill_missing_prefixes(prefix_offsets: dict[int, int], file_size: int) -> list[int]: + """Fill in missing prefixes with the next available offset. + + If a prefix doesn't exist (e.g., no words starting with 'qx'), + we set its offset to the next prefix's offset so the scan will + quickly find nothing and move on. + """ + result = [0] * 676 + + # First pass: fill in known offsets + for idx, offset in prefix_offsets.items(): + result[idx] = offset + + # Second pass: fill missing with next known offset (or file_size) + # Work backwards so each missing entry gets the next valid offset + next_valid = file_size + for idx in range(675, -1, -1): + if idx in prefix_offsets: + next_valid = prefix_offsets[idx] + else: + result[idx] = next_valid + + return result + + +def format_offset_array(offsets: list[int], name: str) -> str: + """Format offset array as C++ constexpr with nice formatting.""" + lines = [f"// Two-letter prefix jump table: {name}[prefix_to_index(c1, c2)] = byte offset"] + lines.append(f"// Prefixes: aa=0, ab=1, ... az=25, ba=26, ... zz=675") + lines.append(f"constexpr uint32_t {name}[676] = {{") + + # Format 13 values per line (fits nicely with 10-digit numbers + commas) + values_per_line = 13 + for i in range(0, 676, values_per_line): + chunk = offsets[i:i + values_per_line] + prefix_start = index_to_prefix(i) + prefix_end = index_to_prefix(min(i + values_per_line - 1, 675)) + values_str = ', '.join(f'{v:>10}' for v in chunk) + lines.append(f" {values_str}, // {prefix_start}-{prefix_end}") + + lines.append("};") + return '\n'.join(lines) + + +def generate_header(idx_offsets: list[int], syn_offsets: list[int] | None, output_path: pathlib.Path) -> None: + """Generate the C++ header file with prefix offset tables.""" + + content = '''#pragma once + +// Auto-generated by generate_dict_index.py. Do not edit manually. +// This file contains pre-computed prefix jump tables for fast dictionary lookup. + +#include + +namespace DictPrefixIndex { + +// Convert two-letter prefix to index (0-675) +// "aa" -> 0, "ab" -> 1, ... "az" -> 25, "ba" -> 26, ... "zz" -> 675 +inline uint16_t prefixToIndex(char c1, char c2) { + // Convert to lowercase and compute index + const int i1 = (c1 | 0x20) - 'a'; // tolower via OR with 0x20 + const int i2 = (c2 | 0x20) - 'a'; + // Bounds check (returns 0 for non-alpha characters) + if (i1 < 0 || i1 > 25 || i2 < 0 || i2 > 25) return 0; + return static_cast(i1 * 26 + i2); +} + +// Check if character is alphabetic (a-z or A-Z) +inline bool isAlpha(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); +} + +''' + + content += format_offset_array(idx_offsets, "dictPrefixOffsets") + content += '\n\n' + + if syn_offsets: + content += format_offset_array(syn_offsets, "synPrefixOffsets") + else: + content += "// No synonym file processed - synPrefixOffsets not generated\n" + content += "constexpr uint32_t synPrefixOffsets[676] = {0};\n" + + content += '\n} // namespace DictPrefixIndex\n' + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content) + print(f"Generated: {output_path}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate StarDict prefix jump tables") + parser.add_argument('--idx', type=str, help='Path to .idx file') + parser.add_argument('--syn', type=str, help='Path to .syn file (optional)') + parser.add_argument('--zip', type=str, help='Path to dictionary zip file (alternative to --idx/--syn)') + parser.add_argument('--output', type=str, required=True, help='Output header path') + + args = parser.parse_args() + + idx_offsets: dict[int, int] = {} + syn_offsets: dict[int, int] | None = None + idx_file_size = 0 + syn_file_size = 0 + + if args.zip: + # Extract from zip file + zip_path = pathlib.Path(args.zip) + print(f"Processing zip file: {zip_path}") + + with zipfile.ZipFile(zip_path, 'r') as zf: + # Find .idx file + idx_name = None + syn_name = None + for name in zf.namelist(): + if name.endswith('.idx'): + idx_name = name + elif name.endswith('.syn'): + syn_name = name + + if not idx_name: + raise SystemExit("No .idx file found in zip") + + print(f"\nParsing index file: {idx_name}") + with zf.open(idx_name) as f: + idx_file_size = zf.getinfo(idx_name).file_size + idx_offsets = parse_idx_file(f, idx_file_size) + + if syn_name: + print(f"\nParsing synonym file: {syn_name}") + with zf.open(syn_name) as f: + syn_file_size = zf.getinfo(syn_name).file_size + syn_offsets = parse_syn_file(f, syn_file_size) + else: + # Read from individual files + if not args.idx: + raise SystemExit("Either --zip or --idx must be provided") + + idx_path = pathlib.Path(args.idx) + print(f"Processing index file: {idx_path}") + idx_file_size = idx_path.stat().st_size + with open(idx_path, 'rb') as f: + idx_offsets = parse_idx_file(f, idx_file_size) + + if args.syn: + syn_path = pathlib.Path(args.syn) + print(f"\nProcessing synonym file: {syn_path}") + syn_file_size = syn_path.stat().st_size + with open(syn_path, 'rb') as f: + syn_offsets = parse_syn_file(f, syn_file_size) + + # Fill in missing prefixes + print("\nFilling missing prefixes...") + idx_offsets_filled = fill_missing_prefixes(idx_offsets, idx_file_size) + syn_offsets_filled = fill_missing_prefixes(syn_offsets, syn_file_size) if syn_offsets else None + + # Generate header + print("\nGenerating header file...") + generate_header(idx_offsets_filled, syn_offsets_filled, pathlib.Path(args.output)) + + # Print some statistics + print("\n=== Statistics ===") + print(f"Index file size: {idx_file_size:,} bytes") + if syn_file_size: + print(f"Synonym file size: {syn_file_size:,} bytes") + + # Show distribution of some common prefixes + print("\nSample prefix offsets:") + for prefix in ['aa', 'he', 'th', 'wo', 'zz']: + idx = prefix_to_index(prefix[0], prefix[1]) + offset = idx_offsets_filled[idx] + pct = (offset / idx_file_size) * 100 if idx_file_size else 0 + print(f" '{prefix}' (index {idx}): offset {offset:,} ({pct:.1f}% into file)") + + +if __name__ == '__main__': + main() diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 8ce32a2..2a253b0 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -53,7 +53,7 @@ class CrossPointSettings { enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; // Short power button press actions - enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3 }; // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; diff --git a/src/activities/dictionary/DictionaryMargins.h b/src/activities/dictionary/DictionaryMargins.h new file mode 100644 index 0000000..e65d929 --- /dev/null +++ b/src/activities/dictionary/DictionaryMargins.h @@ -0,0 +1,56 @@ +#pragma once + +#include "CrossPointSettings.h" +#include "GfxRenderer.h" + +/** + * Calculate content margins for dictionary activities that use button hints. + * Uses the same base margin pattern as EpubReaderActivity, then adds space + * for button hints at the correct logical edges based on orientation. + * + * Physical button locations (fixed on device): + * - Front buttons: physical X=760 (right edge of 800-pixel wide panel) + * - Side buttons: physical Y=44 (top area of 480-pixel tall panel) + * + * These map to different logical edges depending on orientation: + * - Portrait: Front=BOTTOM, Side=RIGHT + * - LandscapeCW: Front=LEFT, Side=BOTTOM + * - PortraitInverted: Front=TOP, Side=LEFT + * - LandscapeCCW: Front=RIGHT, Side=TOP + */ +inline void getDictionaryContentMargins(GfxRenderer& renderer, int* outTop, int* outRight, int* outBottom, + int* outLeft) { + // Start with same base margins as reader (getOrientedViewableTRBL + screenMargin) + renderer.getOrientedViewableTRBL(outTop, outRight, outBottom, outLeft); + *outTop += SETTINGS.screenMargin; + *outLeft += SETTINGS.screenMargin; + *outRight += SETTINGS.screenMargin; + *outBottom += SETTINGS.screenMargin; + + // Add button hint space to the correct edges based on orientation + constexpr int FRONT_BUTTON_SPACE = 45; // 40px button height + 5px padding + constexpr int SIDE_BUTTON_SPACE = 50; // 45px button area + 5px padding + + switch (renderer.getOrientation()) { + case GfxRenderer::Portrait: + // Front buttons at logical BOTTOM, Side buttons at logical RIGHT + *outBottom += FRONT_BUTTON_SPACE; + *outRight += SIDE_BUTTON_SPACE; + break; + case GfxRenderer::LandscapeClockwise: + // Front buttons at logical LEFT, Side buttons at logical BOTTOM + *outLeft += FRONT_BUTTON_SPACE; + *outBottom += SIDE_BUTTON_SPACE; + break; + case GfxRenderer::PortraitInverted: + // Front buttons at logical TOP, Side buttons at logical LEFT + *outTop += FRONT_BUTTON_SPACE; + *outLeft += SIDE_BUTTON_SPACE; + break; + case GfxRenderer::LandscapeCounterClockwise: + // Front buttons at logical RIGHT, Side buttons at logical TOP + *outRight += FRONT_BUTTON_SPACE; + *outTop += SIDE_BUTTON_SPACE; + break; + } +} diff --git a/src/activities/dictionary/DictionaryMenuActivity.cpp b/src/activities/dictionary/DictionaryMenuActivity.cpp new file mode 100644 index 0000000..ddda380 --- /dev/null +++ b/src/activities/dictionary/DictionaryMenuActivity.cpp @@ -0,0 +1,146 @@ +#include "DictionaryMenuActivity.h" + +#include + +#include "DictionaryMargins.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +namespace { +constexpr int MAX_MENU_ITEM_COUNT = 2; +const char* MENU_ITEMS[MAX_MENU_ITEM_COUNT] = {"Select from Screen", "Enter a Word"}; +const char* MENU_DESCRIPTIONS[MAX_MENU_ITEM_COUNT] = {"Choose a word from the current page", + "Type a word to look up"}; +} // namespace + +void DictionaryMenuActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void DictionaryMenuActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Reset selection + selectedIndex = 0; + + // Trigger first update + updateRequired = true; + + xTaskCreate(&DictionaryMenuActivity::taskTrampoline, "DictMenuTask", + 2048, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void DictionaryMenuActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void DictionaryMenuActivity::loop() { + const int menuItemCount = wordSelectionAvailable ? 2 : 1; + + // Handle back button - cancel + // Use wasReleased to consume the full button event + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + // Handle confirm button - select current option + // Use wasReleased to consume the full button event + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + const DictionaryMode mode = + (selectedIndex == 0) ? DictionaryMode::SELECT_FROM_SCREEN : DictionaryMode::ENTER_WORD; + onModeSelected(mode); + return; + } + + // Handle navigation (only if multiple options available) + if (menuItemCount > 1) { + const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left); + const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right); + + if (prevPressed) { + selectedIndex = (selectedIndex + menuItemCount - 1) % menuItemCount; + updateRequired = true; + } else if (nextPressed) { + selectedIndex = (selectedIndex + 1) % menuItemCount; + updateRequired = true; + } + } +} + +void DictionaryMenuActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void DictionaryMenuActivity::render() const { + renderer.clearScreen(); + + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const int menuItemCount = wordSelectionAvailable ? 2 : 1; + + // Calculate usable content area + const int contentWidth = pageWidth - marginLeft - marginRight; + const int contentHeight = pageHeight - marginTop - marginBottom; + + // Draw header with top margin + renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); + + // Draw subtitle + renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 50, "Look up a word"); + + // Draw menu items centered in content area + constexpr int itemHeight = 50; // Height for each menu item (including description) + const int startY = marginTop + (contentHeight - (menuItemCount * itemHeight)) / 2; + + for (int i = 0; i < menuItemCount; i++) { + const int itemY = startY + i * itemHeight; + const bool isSelected = (i == selectedIndex); + + // Draw selection highlight (black fill) for selected item + if (isSelected) { + renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6); + } + + // Draw text: black=false (white text) when selected (on black background) + // black=true (black text) when not selected (on white background) + renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, MENU_ITEMS[i], /*black=*/!isSelected); + renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); + } + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/dictionary/DictionaryMenuActivity.h b/src/activities/dictionary/DictionaryMenuActivity.h new file mode 100644 index 0000000..93f856c --- /dev/null +++ b/src/activities/dictionary/DictionaryMenuActivity.h @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include + +#include + +#include "../Activity.h" + +// Enum for dictionary mode selection +enum class DictionaryMode { ENTER_WORD, SELECT_FROM_SCREEN }; + +/** + * DictionaryMenuActivity presents the user with a choice: + * - "Enter a Word" - Manually type a word to look up + * - "Select from Screen" - Select a word from the current page + * + * The onModeSelected callback is called with the user's choice. + * The onCancel callback is called if the user presses back. + */ +class DictionaryMenuActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + int selectedIndex = 0; + bool updateRequired = false; + const std::function onModeSelected; + const std::function onCancel; + const bool wordSelectionAvailable; // True if we can select from screen (e.g., in EPUB reader) + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + explicit DictionaryMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onModeSelected, + const std::function& onCancel, bool wordSelectionAvailable = true) + : Activity("DictionaryMenu", renderer, mappedInput), + onModeSelected(onModeSelected), + onCancel(onCancel), + wordSelectionAvailable(wordSelectionAvailable) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/dictionary/DictionaryResultActivity.cpp b/src/activities/dictionary/DictionaryResultActivity.cpp new file mode 100644 index 0000000..46be775 --- /dev/null +++ b/src/activities/dictionary/DictionaryResultActivity.cpp @@ -0,0 +1,199 @@ +#include "DictionaryResultActivity.h" + +#include +#include + +#include "DictionaryMargins.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +void DictionaryResultActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void DictionaryResultActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + currentPage = 0; + + // Process definition for display + if (!notFound) { + paginateDefinition(); + } + + updateRequired = true; + + xTaskCreate(&DictionaryResultActivity::taskTrampoline, "DictResultTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void DictionaryResultActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void DictionaryResultActivity::loop() { + // Handle back button - use wasReleased to consume the full button event + // This prevents the release event from propagating to parent activities + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + return; + } + + // Handle confirm button - search another word + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + onSearchAnother(); + return; + } + + // Handle page navigation - use orientation-aware PageBack/PageForward buttons + if (!notFound && pages.size() > 1) { + const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::PageBack) || + mappedInput.wasPressed(MappedInputManager::Button::Left); + const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::PageForward) || + mappedInput.wasPressed(MappedInputManager::Button::Right); + + if (prevPressed && currentPage > 0) { + currentPage--; + updateRequired = true; + } else if (nextPressed && currentPage < static_cast(pages.size()) - 1) { + currentPage++; + updateRequired = true; + } + } +} + +void DictionaryResultActivity::paginateDefinition() { + pages.clear(); + + if (rawDefinition.empty()) { + notFound = true; + return; + } + + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Calculate available area for text (must match render() layout) + constexpr int headerHeight = 80; // Space for word and header (relative to marginTop) + constexpr int footerHeight = 30; // Space for page indicator + const int textMargin = marginLeft + 10; + const int textWidth = pageWidth - textMargin - marginRight - 10; + const int textHeight = pageHeight - marginTop - marginBottom - headerHeight - footerHeight; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + + // Collect all TextBlocks from the HTML parser + std::vector> allBlocks; + DictHtmlParser::parse(rawDefinition, UI_10_FONT_ID, renderer, textWidth, + [&allBlocks](std::shared_ptr block) { allBlocks.push_back(block); }); + + if (allBlocks.empty()) { + notFound = true; + return; + } + + // Paginate: group TextBlocks into pages based on available height + std::vector> currentPageBlocks; + int currentY = 0; + + for (const auto& block : allBlocks) { + // Each TextBlock is one line of text + if (currentY + lineHeight > textHeight && !currentPageBlocks.empty()) { + // Page is full, start new page + pages.push_back(currentPageBlocks); + currentPageBlocks.clear(); + currentY = 0; + } + + currentPageBlocks.push_back(block); + currentY += lineHeight; + } + + // Add remaining blocks as last page + if (!currentPageBlocks.empty()) { + pages.push_back(currentPageBlocks); + } +} + +void DictionaryResultActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void DictionaryResultActivity::render() const { + renderer.clearScreen(); + + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header with top margin + renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); + + // Draw word being looked up (bold) + renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 50, lookupWord.c_str(), true, EpdFontFamily::BOLD); + + if (notFound) { + // Show not found message (centered in content area) + const int centerY = marginTop + (pageHeight - marginTop - marginBottom) / 2; + renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found"); + } else if (!pages.empty()) { + // Draw definition text using TextBlocks with rich formatting + const int textStartY = marginTop + 80; + const int textMargin = marginLeft + 10; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int bottomLimit = pageHeight - marginBottom - 25; // Leave space for page indicator + + const auto& pageBlocks = pages[currentPage]; + int y = textStartY; + + // Render each TextBlock + for (const auto& block : pageBlocks) { + if (y >= bottomLimit) break; + block->render(renderer, UI_10_FONT_ID, textMargin, y); + y += lineHeight; + } + + // Draw page indicator if multiple pages + if (pages.size() > 1) { + char pageIndicator[32]; + snprintf(pageIndicator, sizeof(pageIndicator), "Page %d of %d", currentPage + 1, static_cast(pages.size())); + renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - marginBottom - 5, pageIndicator); + } + } + + // Draw button hints + const char* leftHint = (pages.size() > 1 && currentPage > 0) ? "< Prev" : ""; + const char* rightHint = (pages.size() > 1 && currentPage < static_cast(pages.size()) - 1) ? "Next >" : ""; + const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Search", leftHint, rightHint); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/dictionary/DictionaryResultActivity.h b/src/activities/dictionary/DictionaryResultActivity.h new file mode 100644 index 0000000..17040e6 --- /dev/null +++ b/src/activities/dictionary/DictionaryResultActivity.h @@ -0,0 +1,63 @@ +#pragma once +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include "../Activity.h" + +/** + * DictionaryResultActivity displays a word definition with pagination. + * Supports multi-page definitions with navigation and rich text formatting. + */ +class DictionaryResultActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + const std::string lookupWord; // Named to avoid Arduino's 'word' macro + const std::string rawDefinition; + const std::function onBack; + const std::function onSearchAnother; + + // Pagination - each page contains TextBlocks with styled text + std::vector>> pages; + int currentPage = 0; + bool notFound = false; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void paginateDefinition(); + + public: + /** + * Constructor + * @param renderer Graphics renderer + * @param mappedInput Input manager + * @param wordToLookup The word that was looked up + * @param definition The definition text (HTML will be stripped). Empty = not found. + * @param onBack Callback when user wants to go back to book + * @param onSearchAnother Callback when user wants to search another word + */ + explicit DictionaryResultActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::string& wordToLookup, const std::string& definition, + const std::function& onBack, + const std::function& onSearchAnother) + : Activity("DictionaryResult", renderer, mappedInput), + lookupWord(wordToLookup), + rawDefinition(definition), + onBack(onBack), + onSearchAnother(onSearchAnother), + notFound(definition.empty()) {} + + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/dictionary/DictionarySearchActivity.cpp b/src/activities/dictionary/DictionarySearchActivity.cpp new file mode 100644 index 0000000..c2f1e1e --- /dev/null +++ b/src/activities/dictionary/DictionarySearchActivity.cpp @@ -0,0 +1,261 @@ +#include "DictionarySearchActivity.h" + +#include +#include +#include + +#include "DictionaryMargins.h" +#include "DictionaryResultActivity.h" +#include "MappedInputManager.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "fontIds.h" + +namespace { +// Dictionary path on SD card +constexpr const char* DICT_BASE_PATH = "/dictionaries/dict-data"; + +// Global dictionary instance (lazy initialized) +StarDict* g_dictionary = nullptr; + +StarDict& getDictionary() { + if (!g_dictionary) { + g_dictionary = new StarDict(DICT_BASE_PATH); + if (!g_dictionary->begin()) { + Serial.printf("[%lu] [DICT] Failed to initialize dictionary\n", millis()); + } + } + return *g_dictionary; +} +} // namespace + +void DictionarySearchActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void DictionarySearchActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + isSearching = false; + keyboardShown = false; + searchStatus = ""; + updateRequired = true; + + xTaskCreate(&DictionarySearchActivity::taskTrampoline, "DictSearchTask", + 4096, // Stack size (needs more for dictionary operations) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // If no initial word provided, show keyboard + if (searchWord.empty()) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + keyboardShown = true; + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Enter Word", "", 10, + 64, // maxLength + false, // not password + [this](const std::string& word) { + // User entered a word + exitActivity(); + searchWord = word; + keyboardShown = false; + if (!word.empty()) { + performSearch(word); + } else { + onBack(); + } + }, + [this]() { + // User cancelled keyboard + exitActivity(); + keyboardShown = false; + onBack(); + })); + xSemaphoreGive(renderingMutex); + } else { + // Perform search with provided word + performSearch(searchWord); + } +} + +void DictionarySearchActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void DictionarySearchActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + // Handle back button - use wasReleased to consume the full button event + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + return; + } +} + +void DictionarySearchActivity::performSearch(const std::string& word) { + isSearching = true; + searchStatus = "Searching..."; + updateRequired = true; + + // Small delay to allow render + vTaskDelay(50 / portTICK_PERIOD_MS); + + // Initialize dictionary if needed + StarDict& dict = getDictionary(); + + if (!dict.isReady()) { + searchStatus = "Dictionary not found"; + isSearching = false; + updateRequired = true; + return; + } + + // Perform lookup + const auto result = dict.lookup(word); + + if (result.found) { + showResult(result.word, result.definition); + } else { + showNotFound(word); + } +} + +void DictionarySearchActivity::showResult(const std::string& word, const std::string& definition) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + isSearching = false; + exitActivity(); + enterNewActivity(new DictionaryResultActivity( + renderer, mappedInput, word, definition, + [this]() { + // Back from result + exitActivity(); + onBack(); + }, + [this]() { + // Search another word + exitActivity(); + searchWord = ""; + keyboardShown = true; + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Enter Word", "", 10, 64, false, + [this](const std::string& newWord) { + exitActivity(); + keyboardShown = false; + if (!newWord.empty()) { + performSearch(newWord); + } else { + onBack(); + } + }, + [this]() { + exitActivity(); + keyboardShown = false; + onBack(); + })); + })); + xSemaphoreGive(renderingMutex); +} + +void DictionarySearchActivity::showNotFound(const std::string& word) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + isSearching = false; + exitActivity(); + enterNewActivity(new DictionaryResultActivity( + renderer, mappedInput, word, "", // Empty definition = not found + [this]() { + // Back from result + exitActivity(); + onBack(); + }, + [this]() { + // Search another word + exitActivity(); + searchWord = ""; + keyboardShown = true; + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Enter Word", "", 10, 64, false, + [this](const std::string& newWord) { + exitActivity(); + keyboardShown = false; + if (!newWord.empty()) { + performSearch(newWord); + } else { + onBack(); + } + }, + [this]() { + exitActivity(); + keyboardShown = false; + onBack(); + })); + })); + xSemaphoreGive(renderingMutex); +} + +void DictionarySearchActivity::displayTaskLoop() { + int animationCounter = 0; + constexpr int ANIMATION_INTERVAL = 30; // ~300ms at 10ms per tick + + while (true) { + // Handle animation updates when searching + if (isSearching && !subActivity) { + animationCounter++; + if (animationCounter >= ANIMATION_INTERVAL) { + animationCounter = 0; + animationFrame = (animationFrame + 1) % 3; + updateRequired = true; + } + } + + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void DictionarySearchActivity::render() const { + renderer.clearScreen(); + + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header with top margin + renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 15, "Dictionary", true, EpdFontFamily::BOLD); + + if (isSearching) { + // Show searching status with word and animated ellipsis + // Center in content area (accounting for margins) + const int centerY = marginTop + (pageHeight - marginTop - marginBottom) / 2; + + // Build animated ellipsis + const char* dots = (animationFrame == 0) ? "." : (animationFrame == 1) ? ".." : "..."; + + // Show "Searching for 'word'..." + char statusText[128]; + snprintf(statusText, sizeof(statusText), "Searching for '%s'%s", searchWord.c_str(), dots); + renderer.drawCenteredText(UI_10_FONT_ID, centerY, statusText); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/dictionary/DictionarySearchActivity.h b/src/activities/dictionary/DictionarySearchActivity.h new file mode 100644 index 0000000..74b6c43 --- /dev/null +++ b/src/activities/dictionary/DictionarySearchActivity.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +#include +#include + +#include "../ActivityWithSubactivity.h" + +/** + * DictionarySearchActivity handles the dictionary lookup flow: + * - If no word is provided, shows keyboard for entry + * - Performs StarDict lookup + * - Shows result in DictionaryResultActivity + */ +class DictionarySearchActivity final : public ActivityWithSubactivity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + const std::function onBack; + std::string searchWord; + bool keyboardShown = false; + bool isSearching = false; + std::string searchStatus; + int animationFrame = 0; // For ellipsis animation (0-2) + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void performSearch(const std::string& word); + void showResult(const std::string& word, const std::string& definition); + void showNotFound(const std::string& word); + + public: + /** + * Constructor + * @param renderer Graphics renderer + * @param mappedInput Input manager + * @param onBack Callback when user wants to go back + * @param initialWord Optional word to look up immediately (if empty, shows keyboard) + */ + explicit DictionarySearchActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack, const std::string& initialWord = "") + : ActivityWithSubactivity("DictionarySearch", renderer, mappedInput), onBack(onBack), searchWord(initialWord) {} + + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/dictionary/EpubWordSelectionActivity.cpp b/src/activities/dictionary/EpubWordSelectionActivity.cpp new file mode 100644 index 0000000..61dc279 --- /dev/null +++ b/src/activities/dictionary/EpubWordSelectionActivity.cpp @@ -0,0 +1,263 @@ +#include "EpubWordSelectionActivity.h" + +#include +#include + +#include +#include + +#include "DictionaryMargins.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +void EpubWordSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void EpubWordSelectionActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + selectedWordIndex = 0; + currentLineIndex = 0; + + // Build list of all words on the page + buildWordList(); + + updateRequired = true; + + xTaskCreate(&EpubWordSelectionActivity::taskTrampoline, "WordSelectTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void EpubWordSelectionActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void EpubWordSelectionActivity::buildWordList() { + allWords.clear(); + + if (!page) return; + + const int lineHeight = renderer.getLineHeight(fontId); + + for (const auto& element : page->elements) { + // All page elements are PageLine (only type in PageElementTag enum) + const auto* pageLine = static_cast(element.get()); + if (!pageLine) continue; + + const auto& textBlock = pageLine->getTextBlock(); + + if (!textBlock || textBlock->getWordCount() == 0) { + continue; + } + + const auto& words = textBlock->getWords(); + const auto& xPositions = textBlock->getWordXPositions(); + const auto& styles = textBlock->getWordStyles(); + + auto wordIt = words.begin(); + auto xPosIt = xPositions.begin(); + auto styleIt = styles.begin(); + + while (wordIt != words.end() && xPosIt != xPositions.end() && styleIt != styles.end()) { + // Skip whitespace-only words + const std::string& wordText = *wordIt; + bool hasAlpha = false; + for (char c : wordText) { + if (std::isalpha(static_cast(c))) { + hasAlpha = true; + break; + } + } + + if (hasAlpha) { + WordInfo info; + info.text = wordText; + info.x = *xPosIt + pageLine->xPos + xOffset; + info.y = pageLine->yPos + yOffset; + info.width = renderer.getTextWidth(fontId, wordText.c_str(), *styleIt); + info.height = lineHeight; + info.style = *styleIt; + allWords.push_back(info); + } + + ++wordIt; + ++xPosIt; + ++styleIt; + } + } +} + +int EpubWordSelectionActivity::findLineForWordIndex(int wordIndex) const { + if (wordIndex < 0 || wordIndex >= static_cast(allWords.size())) return 0; + + const int targetY = allWords[wordIndex].y; + int lineIdx = 0; + int lastY = -1; + + for (size_t i = 0; i <= static_cast(wordIndex); i++) { + if (allWords[i].y != lastY) { + if (lastY >= 0) lineIdx++; + lastY = allWords[i].y; + } + } + + return lineIdx; +} + +int EpubWordSelectionActivity::findWordIndexForLine(int lineIndex) const { + if (allWords.empty()) return 0; + + int currentLine = 0; + int lastY = allWords[0].y; + + for (size_t i = 0; i < allWords.size(); i++) { + if (allWords[i].y != lastY) { + currentLine++; + lastY = allWords[i].y; + } + + if (currentLine == lineIndex) { + return static_cast(i); + } + } + + // If line not found, return last word + return static_cast(allWords.size()) - 1; +} + +void EpubWordSelectionActivity::loop() { + if (allWords.empty()) { + onCancel(); + return; + } + + // Handle back button - cancel + // Use wasReleased to consume the full button event + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onCancel(); + return; + } + + // Handle confirm button - select current word + // Use wasReleased to consume the full button event + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Clean up the word (remove leading/trailing punctuation) + std::string selectedWord = allWords[selectedWordIndex].text; + + // Strip em-space prefix if present + if (selectedWord.size() >= 3 && static_cast(selectedWord[0]) == 0xE2 && + static_cast(selectedWord[1]) == 0x80 && static_cast(selectedWord[2]) == 0x83) { + selectedWord = selectedWord.substr(3); + } + + // Strip leading/trailing non-alpha characters + while (!selectedWord.empty() && !std::isalpha(static_cast(selectedWord.front()))) { + selectedWord.erase(0, 1); + } + while (!selectedWord.empty() && !std::isalpha(static_cast(selectedWord.back()))) { + selectedWord.pop_back(); + } + + if (!selectedWord.empty()) { + onWordSelected(selectedWord); + } else { + onCancel(); + } + return; + } + + // Handle navigation + const bool leftPressed = mappedInput.wasPressed(MappedInputManager::Button::Left); + const bool rightPressed = mappedInput.wasPressed(MappedInputManager::Button::Right); + const bool upPressed = mappedInput.wasPressed(MappedInputManager::Button::Up); + const bool downPressed = mappedInput.wasPressed(MappedInputManager::Button::Down); + + if (leftPressed && selectedWordIndex > 0) { + selectedWordIndex--; + currentLineIndex = findLineForWordIndex(selectedWordIndex); + updateRequired = true; + } else if (rightPressed && selectedWordIndex < static_cast(allWords.size()) - 1) { + selectedWordIndex++; + currentLineIndex = findLineForWordIndex(selectedWordIndex); + updateRequired = true; + } else if (upPressed) { + // Move to previous line + if (currentLineIndex > 0) { + currentLineIndex--; + selectedWordIndex = findWordIndexForLine(currentLineIndex); + updateRequired = true; + } + } else if (downPressed) { + // Move to next line + const int lastLine = findLineForWordIndex(static_cast(allWords.size()) - 1); + if (currentLineIndex < lastLine) { + currentLineIndex++; + selectedWordIndex = findWordIndexForLine(currentLineIndex); + updateRequired = true; + } + } +} + +void EpubWordSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void EpubWordSelectionActivity::render() const { + renderer.clearScreen(); + + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + + // Draw the page content (uses pre-calculated offsets from reader) + // The page already has proper offsets, so render as-is + if (page) { + page->render(renderer, fontId, xOffset, yOffset); + } + + // Highlight the selected word with an inverted rectangle + if (!allWords.empty() && selectedWordIndex >= 0 && selectedWordIndex < static_cast(allWords.size())) { + const WordInfo& selected = allWords[selectedWordIndex]; + + // Draw selection box (inverted colors) + constexpr int padding = 2; + renderer.fillRect(selected.x - padding, selected.y - padding, selected.width + padding * 2, + selected.height + padding * 2); + + // Redraw the word in white on black + renderer.drawText(fontId, selected.x, selected.y, selected.text.c_str(), false, selected.style); + } + + // Draw instruction text - position it just above the front button area + const auto screenHeight = renderer.getScreenHeight(); + renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10, "Navigate with arrows, select with confirm"); + + // Draw button hints + const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< >", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(EInkDisplay::FAST_REFRESH); +} diff --git a/src/activities/dictionary/EpubWordSelectionActivity.h b/src/activities/dictionary/EpubWordSelectionActivity.h new file mode 100644 index 0000000..e54284a --- /dev/null +++ b/src/activities/dictionary/EpubWordSelectionActivity.h @@ -0,0 +1,79 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "../Activity.h" + +/** + * EpubWordSelectionActivity allows selecting a word from the current EPUB page. + * Displays the page with a cursor that can navigate between words. + */ +class EpubWordSelectionActivity final : public Activity { + // Word info for selection + struct WordInfo { + std::string text; // Named 'text' to avoid Arduino's 'word' macro + int16_t x; + int16_t y; + int16_t width; + int16_t height; + EpdFontFamily::Style style; + }; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + const std::unique_ptr page; + const int fontId; + const int xOffset; + const int yOffset; + const std::function onWordSelected; + const std::function onCancel; + + // Word navigation state + std::vector allWords; + int selectedWordIndex = 0; + int currentLineIndex = 0; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + void buildWordList(); + int findWordIndexForLine(int lineIndex) const; + int findLineForWordIndex(int wordIndex) const; + + public: + /** + * Constructor + * @param renderer Graphics renderer + * @param mappedInput Input manager + * @param page The current page to select words from (ownership transferred) + * @param fontId Font ID used for rendering + * @param xOffset X offset for rendering + * @param yOffset Y offset for rendering + * @param onWordSelected Callback when a word is selected + * @param onCancel Callback when selection is cancelled + */ + explicit EpubWordSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr page, + int fontId, int xOffset, int yOffset, + const std::function& onWordSelected, + const std::function& onCancel) + : Activity("EpubWordSelection", renderer, mappedInput), + page(std::move(page)), + fontId(fontId), + xOffset(xOffset), + yOffset(yOffset), + onWordSelected(onWordSelected), + onCancel(onCancel) {} + + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6ff39c5..2417517 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -11,6 +11,9 @@ #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "ScreenComponents.h" +#include "activities/dictionary/DictionaryMenuActivity.h" +#include "activities/dictionary/DictionarySearchActivity.h" +#include "activities/dictionary/EpubWordSelectionActivity.h" #include "fontIds.h" namespace { @@ -163,6 +166,89 @@ void EpubReaderActivity::loop() { return; } + // Dictionary power button press + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::DICTIONARY && + mappedInput.wasReleased(MappedInputManager::Button::Power)) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new DictionaryMenuActivity( + renderer, mappedInput, + [this](DictionaryMode mode) { + // CRITICAL: Cache all needed values BEFORE exitActivity() destroys the lambda's owner + // The lambda is stored in DictionaryMenuActivity, so exitActivity() destroys it + GfxRenderer& cachedRenderer = renderer; + MappedInputManager& cachedMappedInput = mappedInput; + Section* cachedSection = section.get(); + SemaphoreHandle_t cachedMutex = renderingMutex; + EpubReaderActivity* self = this; + + // Handle dictionary mode selection - exitActivity deletes DictionaryMenuActivity + exitActivity(); + + if (mode == DictionaryMode::ENTER_WORD) { + // Enter word mode - show keyboard and search + self->enterNewActivity(new DictionarySearchActivity(cachedRenderer, cachedMappedInput, + [self]() { + // On back from dictionary + self->exitActivity(); + self->updateRequired = true; + }, + "")); // Empty string = show keyboard + } else { + // Select from screen mode - show word selection on current page + if (cachedSection) { + xSemaphoreTake(cachedMutex, portMAX_DELAY); + auto page = cachedSection->loadPageFromSectionFile(); + if (page) { + // Get margins for word selection positioning + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + cachedRenderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + + // Cache the font ID before creating activity + const int cachedFontId = SETTINGS.getReaderFontId(); + + self->enterNewActivity(new EpubWordSelectionActivity( + cachedRenderer, cachedMappedInput, std::move(page), cachedFontId, orientedMarginLeft, + orientedMarginTop, + [self](const std::string& selectedWord) { + // Word selected - look it up + self->exitActivity(); + self->enterNewActivity(new DictionarySearchActivity(self->renderer, self->mappedInput, + [self]() { + self->exitActivity(); + self->updateRequired = true; + }, + selectedWord)); + }, + [self]() { + // Cancelled word selection + self->exitActivity(); + self->updateRequired = true; + })); + xSemaphoreGive(cachedMutex); + } else { + xSemaphoreGive(cachedMutex); + self->updateRequired = true; + } + } else { + self->updateRequired = true; + } + } + }, + [this]() { + // Cancelled dictionary menu - cache self before exitActivity destroys the lambda + EpubReaderActivity* self = this; + exitActivity(); + self->updateRequired = true; + }, + section != nullptr)); // Word selection only available if section is loaded + xSemaphoreGive(renderingMutex); + return; + } + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 943fdb4..7abdb78 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -42,7 +42,8 @@ const SettingInfo controlsSettings[controlsSettingsCount] = { SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), - SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})}; + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, + {"Ignore", "Sleep", "Page Turn", "Dictionary"})}; constexpr int systemSettingsCount = 5; const SettingInfo systemSettings[systemSettingsCount] = { diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 8c36ac3..4ac8dc2 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -1,5 +1,6 @@ #include "KeyboardEntryActivity.h" +#include "activities/dictionary/DictionaryMargins.h" #include "MappedInputManager.h" #include "fontIds.h" @@ -39,7 +40,7 @@ void KeyboardEntryActivity::onEnter() { updateRequired = true; xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity", - 2048, // Stack size + 4096, // Stack size (increased from 2048) this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -238,22 +239,25 @@ void KeyboardEntryActivity::loop() { updateRequired = true; } - // Cancel - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + // Cancel - use wasReleased to consume the full button event and prevent propagation + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (onCancel) { onCancel(); } - updateRequired = true; } } void KeyboardEntryActivity::render() const { + // Get margins using same pattern as reader + button hint space + int marginTop, marginRight, marginBottom, marginLeft; + getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft); + const auto pageWidth = renderer.getScreenWidth(); renderer.clearScreen(); - // Draw title - renderer.drawCenteredText(UI_10_FONT_ID, startY, title.c_str()); + // Draw title with top margin + renderer.drawCenteredText(UI_10_FONT_ID, marginTop + startY, title.c_str()); // Draw input field const int inputY = startY + 22;