crosspoint-reader/src/activities/dictionary/EpubWordSelectionActivity.cpp

265 lines
8.0 KiB
C++
Raw Normal View History

2026-01-22 12:42:01 -05:00
#include "EpubWordSelectionActivity.h"
#include <EInkDisplay.h>
#include <GfxRenderer.h>
#include <algorithm>
#include <cctype>
#include "DictionaryMargins.h"
#include "MappedInputManager.h"
#include "fontIds.h"
void EpubWordSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubWordSelectionActivity*>(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;
2026-01-26 13:56:36 -05:00
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
2026-01-22 12:42:01 -05:00
}
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<PageLine*>(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<unsigned char>(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<int>(allWords.size())) return 0;
const int targetY = allWords[wordIndex].y;
int lineIdx = 0;
int lastY = -1;
for (size_t i = 0; i <= static_cast<size_t>(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<int>(i);
}
}
// If line not found, return last word
return static_cast<int>(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<uint8_t>(selectedWord[0]) == 0xE2 &&
static_cast<uint8_t>(selectedWord[1]) == 0x80 && static_cast<uint8_t>(selectedWord[2]) == 0x83) {
selectedWord = selectedWord.substr(3);
}
// Strip leading/trailing non-alpha characters
while (!selectedWord.empty() && !std::isalpha(static_cast<unsigned char>(selectedWord.front()))) {
selectedWord.erase(0, 1);
}
while (!selectedWord.empty() && !std::isalpha(static_cast<unsigned char>(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<int>(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<int>(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<int>(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);
}