#include "DictionaryWordSelectActivity.h" #include #include #include #include "CrossPointSettings.h" #include "DictionaryDefinitionActivity.h" #include "DictionarySuggestionsActivity.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/Dictionary.h" #include "util/LookupHistory.h" void DictionaryWordSelectActivity::onEnter() { ActivityWithSubactivity::onEnter(); extractWords(); mergeHyphenatedWords(); if (!rows.empty()) { currentRow = static_cast(rows.size()) / 3; currentWordInRow = 0; } requestUpdate(); } void DictionaryWordSelectActivity::onExit() { ActivityWithSubactivity::onExit(); } bool DictionaryWordSelectActivity::isLandscape() const { return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW || orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW; } bool DictionaryWordSelectActivity::isInverted() const { return orientation == CrossPointSettings::ORIENTATION::INVERTED; } void DictionaryWordSelectActivity::extractWords() { words.clear(); rows.clear(); for (const auto& element : page->elements) { // PageLine is the only concrete PageElement type, identified by tag const auto* line = static_cast(element.get()); const auto& block = line->getBlock(); if (!block) continue; const auto& wordList = block->getWords(); const auto& xPosList = block->getWordXpos(); auto wordIt = wordList.begin(); auto xIt = xPosList.begin(); while (wordIt != wordList.end() && xIt != xPosList.end()) { int16_t screenX = line->xPos + static_cast(*xIt) + marginLeft; int16_t screenY = line->yPos + marginTop; const std::string& wordText = *wordIt; // Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94) std::vector splitStarts; size_t partStart = 0; for (size_t i = 0; i < wordText.size();) { if (i + 2 < wordText.size() && static_cast(wordText[i]) == 0xE2 && static_cast(wordText[i + 1]) == 0x80 && (static_cast(wordText[i + 2]) == 0x93 || static_cast(wordText[i + 2]) == 0x94)) { if (i > partStart) splitStarts.push_back(partStart); i += 3; partStart = i; } else { i++; } } if (partStart < wordText.size()) splitStarts.push_back(partStart); if (splitStarts.size() <= 1 && partStart == 0) { // No dashes found -- add as a single word int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str()); words.push_back({wordText, screenX, screenY, wordWidth, 0}); } else { // Add each part as a separate selectable word for (size_t si = 0; si < splitStarts.size(); si++) { size_t start = splitStarts[si]; size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size(); // Find actual end by trimming any trailing dash bytes size_t textEnd = end; while (textEnd > start && textEnd <= wordText.size()) { if (textEnd >= 3 && static_cast(wordText[textEnd - 3]) == 0xE2 && static_cast(wordText[textEnd - 2]) == 0x80 && (static_cast(wordText[textEnd - 1]) == 0x93 || static_cast(wordText[textEnd - 1]) == 0x94)) { textEnd -= 3; } else { break; } } std::string part = wordText.substr(start, textEnd - start); if (part.empty()) continue; std::string prefix = wordText.substr(0, start); int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str()); int16_t partWidth = renderer.getTextWidth(fontId, part.c_str()); words.push_back({part, static_cast(screenX + offsetX), screenY, partWidth, 0}); } } ++wordIt; ++xIt; } } // Group words into rows by Y position if (words.empty()) return; int16_t currentY = words[0].screenY; rows.push_back({currentY, {}}); for (size_t i = 0; i < words.size(); i++) { // Allow small Y tolerance (words on same line may differ by a pixel) if (std::abs(words[i].screenY - currentY) > 2) { currentY = words[i].screenY; rows.push_back({currentY, {}}); } words[i].row = static_cast(rows.size() - 1); rows.back().wordIndices.push_back(static_cast(i)); } } void DictionaryWordSelectActivity::mergeHyphenatedWords() { for (size_t r = 0; r + 1 < rows.size(); r++) { if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue; int lastWordIdx = rows[r].wordIndices.back(); const std::string& lastWord = words[lastWordIdx].text; if (lastWord.empty()) continue; // Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD) bool endsWithHyphen = false; if (lastWord.back() == '-') { endsWithHyphen = true; } else if (lastWord.size() >= 2 && static_cast(lastWord[lastWord.size() - 2]) == 0xC2 && static_cast(lastWord[lastWord.size() - 1]) == 0xAD) { endsWithHyphen = true; } if (!endsWithHyphen) continue; int nextWordIdx = rows[r + 1].wordIndices.front(); // Set bidirectional continuation links for highlighting both parts words[lastWordIdx].continuationIndex = nextWordIdx; words[nextWordIdx].continuationOf = lastWordIdx; // Build merged lookup text: remove trailing hyphen and combine std::string firstPart = lastWord; if (firstPart.back() == '-') { firstPart.pop_back(); } else if (firstPart.size() >= 2 && static_cast(firstPart[firstPart.size() - 2]) == 0xC2 && static_cast(firstPart[firstPart.size() - 1]) == 0xAD) { firstPart.erase(firstPart.size() - 2); } std::string merged = firstPart + words[nextWordIdx].text; words[lastWordIdx].lookupText = merged; words[nextWordIdx].lookupText = merged; words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part } // Cross-page hyphenation: last word on page + first word of next page if (!nextPageFirstWord.empty() && !rows.empty()) { int lastWordIdx = rows.back().wordIndices.back(); const std::string& lastWord = words[lastWordIdx].text; if (!lastWord.empty()) { bool endsWithHyphen = false; if (lastWord.back() == '-') { endsWithHyphen = true; } else if (lastWord.size() >= 2 && static_cast(lastWord[lastWord.size() - 2]) == 0xC2 && static_cast(lastWord[lastWord.size() - 1]) == 0xAD) { endsWithHyphen = true; } if (endsWithHyphen) { std::string firstPart = lastWord; if (firstPart.back() == '-') { firstPart.pop_back(); } else if (firstPart.size() >= 2 && static_cast(firstPart[firstPart.size() - 2]) == 0xC2 && static_cast(firstPart[firstPart.size() - 1]) == 0xAD) { firstPart.erase(firstPart.size() - 2); } std::string merged = firstPart + nextPageFirstWord; words[lastWordIdx].lookupText = merged; } } } // Remove empty rows that may result from merging (e.g., a row whose only word was a continuation) rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end()); } void DictionaryWordSelectActivity::loop() { // Delegate to subactivity (definition/suggestions screen) if active if (subActivity) { subActivity->loop(); if (pendingBackFromDef) { pendingBackFromDef = false; exitActivity(); requestUpdate(); } if (pendingExitToReader) { pendingExitToReader = false; exitActivity(); onBack(); } return; } if (words.empty()) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { onBack(); } return; } bool changed = false; const bool landscape = isLandscape(); const bool inverted = isInverted(); // Button mapping depends on physical orientation: // - Portrait: side Up/Down = row nav, face Left/Right = word nav // - Inverted: same axes but reversed directions (device is flipped 180) // - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed; if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) { rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left); rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right); wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Down); wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Up); } else if (landscape) { rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right); rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left); wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Up); wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Down); } else if (inverted) { rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Down); rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Up); wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right); wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left); } else { // Portrait (default) rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Up); rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || mappedInput.wasReleased(MappedInputManager::Button::Down); wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left); wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right); } const int rowCount = static_cast(rows.size()); // Helper: find closest word by X position in a target row auto findClosestWord = [&](int targetRow) { int wordIdx = rows[currentRow].wordIndices[currentWordInRow]; int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2; int bestMatch = 0; int bestDist = INT_MAX; for (int i = 0; i < static_cast(rows[targetRow].wordIndices.size()); i++) { int idx = rows[targetRow].wordIndices[i]; int centerX = words[idx].screenX + words[idx].width / 2; int dist = std::abs(centerX - currentCenterX); if (dist < bestDist) { bestDist = dist; bestMatch = i; } } return bestMatch; }; // Move to previous row (wrap to bottom) if (rowPrevPressed) { int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1; currentWordInRow = findClosestWord(targetRow); currentRow = targetRow; changed = true; } // Move to next row (wrap to top) if (rowNextPressed) { int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0; currentWordInRow = findClosestWord(targetRow); currentRow = targetRow; changed = true; } // Move to previous word (wrap to end of previous row) if (wordPrevPressed) { if (currentWordInRow > 0) { currentWordInRow--; } else if (rowCount > 1) { currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1; currentWordInRow = static_cast(rows[currentRow].wordIndices.size()) - 1; } changed = true; } // Move to next word (wrap to start of next row) if (wordNextPressed) { if (currentWordInRow < static_cast(rows[currentRow].wordIndices.size()) - 1) { currentWordInRow++; } else if (rowCount > 1) { currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0; currentWordInRow = 0; } changed = true; } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { int wordIdx = rows[currentRow].wordIndices[currentWordInRow]; const std::string& rawWord = words[wordIdx].lookupText; std::string cleaned = Dictionary::cleanWord(rawWord); if (cleaned.empty()) { { Activity::RenderLock lock(*this); GUI.drawPopup(renderer, "No word"); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } vTaskDelay(1000 / portTICK_PERIOD_MS); requestUpdate(); return; } Rect popupLayout; { Activity::RenderLock lock(*this); popupLayout = GUI.drawPopup(renderer, "Looking up..."); } bool cancelled = false; std::string definition = Dictionary::lookup( cleaned, [this, &popupLayout](int percent) { Activity::RenderLock lock(*this); GUI.fillPopupProgress(renderer, popupLayout, percent); }, [this, &cancelled]() -> bool { mappedInput.update(); if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { cancelled = true; return true; } return false; }); if (cancelled) { requestUpdate(); return; } LookupHistory::addWord(cachePath, cleaned); if (!definition.empty()) { enterNewActivity(new DictionaryDefinitionActivity( renderer, mappedInput, cleaned, definition, fontId, orientation, [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); return; } // Try stem variants (e.g., "jumped" -> "jump") auto stems = Dictionary::getStemVariants(cleaned); for (const auto& stem : stems) { std::string stemDef = Dictionary::lookup(stem); if (!stemDef.empty()) { enterNewActivity(new DictionaryDefinitionActivity( renderer, mappedInput, stem, stemDef, fontId, orientation, [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); return; } } // Find similar words for suggestions auto similar = Dictionary::findSimilar(cleaned, 6); if (!similar.empty()) { enterNewActivity(new DictionarySuggestionsActivity( renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath, [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); return; } { Activity::RenderLock lock(*this); GUI.drawPopup(renderer, "Not found"); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } vTaskDelay(1500 / portTICK_PERIOD_MS); requestUpdate(); return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { onBack(); return; } if (changed) { requestUpdate(); } } void DictionaryWordSelectActivity::render(Activity::RenderLock&&) { renderer.clearScreen(); // Render the page content page->render(renderer, fontId, marginLeft, marginTop); if (!words.empty() && currentRow < static_cast(rows.size())) { int wordIdx = rows[currentRow].wordIndices[currentWordInRow]; const auto& w = words[wordIdx]; // Draw inverted highlight behind selected word const int lineHeight = renderer.getLineHeight(fontId); renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true); renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false); // Highlight the other half of a hyphenated word (whether selecting first or second part) int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1; if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) { otherIdx = w.continuationIndex; } if (otherIdx >= 0) { const auto& other = words[otherIdx]; renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true); renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false); } } drawHints(); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } void DictionaryWordSelectActivity::drawHints() { // Draw button hints in portrait orientation (matching physical buttons and theme). // Any hint whose area would overlap the selected word highlight is completely skipped, // leaving the page content underneath visible. const auto origOrientation = renderer.getOrientation(); // Get portrait dimensions for overlap math renderer.setOrientation(GfxRenderer::Orientation::Portrait); const int portW = renderer.getScreenWidth(); // 480 in portrait const int portH = renderer.getScreenHeight(); // 800 in portrait renderer.setOrientation(origOrientation); // Bottom button constants (match LyraTheme::drawButtonHints) constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight constexpr int buttonWidth = 80; constexpr int cornerRadius = 6; constexpr int textYOffset = 7; constexpr int smallButtonHeight = 15; constexpr int buttonPositions[] = {58, 146, 254, 342}; // Side button constants (match LyraTheme::drawSideButtonHints) constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth constexpr int sideButtonHeight = 78; constexpr int sideButtonGap = 5; constexpr int sideTopY = 345; // topHintButtonY const int sideX = portW - sideButtonWidth; const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap}; // Labels for face and side buttons depend on orientation, // because the physical-to-logical mapping rotates with the screen. const char* facePrev; // label for physical Left face button const char* faceNext; // label for physical Right face button const char* sideTop; // label for physical top side button (PageBack) const char* sideBottom; // label for physical bottom side button (PageForward) const bool landscape = isLandscape(); const bool inverted = isInverted(); if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) { facePrev = "Line Up"; faceNext = "Line Dn"; sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word"; } else if (landscape) { // LANDSCAPE_CCW facePrev = "Line Dn"; faceNext = "Line Up"; sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB"; } else if (inverted) { facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word"; sideTop = "Line Dn"; sideBottom = "Line Up"; } else { // Portrait (default) facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB"; sideTop = "Line Up"; sideBottom = "Line Dn"; } const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext); const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4}; const char* sideLabels[] = {sideTop, sideBottom}; // ---- Determine which hints overlap the selected word ---- bool hideHint[4] = {false, false, false, false}; bool hideSide[2] = {false, false}; if (!words.empty() && currentRow < static_cast(rows.size())) { const int lineHeight = renderer.getLineHeight(fontId); // Collect bounding boxes of the selected word (and its continuation) in current-orientation coords. struct Box { int x, y, w, h; }; Box boxes[2]; int boxCount = 0; int wordIdx = rows[currentRow].wordIndices[currentWordInRow]; const auto& sel = words[wordIdx]; boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2}; boxCount = 1; int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1; if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) { otherIdx = sel.continuationIndex; } if (otherIdx >= 0) { const auto& other = words[otherIdx]; boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2}; boxCount = 2; } // Convert each box from the current orientation to portrait coordinates, // then check overlap against both bottom and side button hints. for (int b = 0; b < boxCount; b++) { int px, py, pw, ph; if (origOrientation == GfxRenderer::Orientation::Portrait) { px = boxes[b].x; py = boxes[b].y; pw = boxes[b].w; ph = boxes[b].h; } else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) { px = portW - boxes[b].x - boxes[b].w; py = portH - boxes[b].y - boxes[b].h; pw = boxes[b].w; ph = boxes[b].h; } else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) { px = boxes[b].y; py = portH - boxes[b].x - boxes[b].w; pw = boxes[b].h; ph = boxes[b].w; } else { px = portW - boxes[b].y - boxes[b].h; py = boxes[b].x; pw = boxes[b].h; ph = boxes[b].w; } // Bottom button overlap int hintTop = portH - buttonHeight; if (py + ph > hintTop) { for (int i = 0; i < 4; i++) { if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) { hideHint[i] = true; } } } // Side button overlap if (px + pw > sideX) { for (int s = 0; s < 2; s++) { if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) { hideSide[s] = true; } } } } } // ---- Draw all hints in portrait mode ---- // Hidden buttons are skipped entirely so the page content underneath stays visible. renderer.setOrientation(GfxRenderer::Orientation::Portrait); // Bottom face buttons for (int i = 0; i < 4; i++) { if (hideHint[i]) continue; const int x = buttonPositions[i]; renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false); if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') { renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, false, true); const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]); const int tx = x + (buttonWidth - 1 - tw) / 2; renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]); } else { renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true, true, false, false, true); } } // Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation) const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW); for (int i = 0; i < 2; i++) { if (hideSide[i]) continue; if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue; // Solid background renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false); // Outline (rounded on inner side, square on screen edge — matches theme) renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false, true, false, true); // Truncate text if it would overflow the button height const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight); const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str()); if (useCCW) { // Text reads top-to-bottom (90° CCW rotation): y starts near top of button renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str()); } else { // Text reads bottom-to-top (90° CW rotation): y starts near bottom of button renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str()); } } renderer.setOrientation(origOrientation); }