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;
|
2026-01-28 15:57:31 -05:00
|
|
|
const bool hasAlpha = std::any_of(wordText.begin(), wordText.end(),
|
|
|
|
|
[](char c) { return std::isalpha(static_cast<unsigned char>(c)); });
|
2026-01-22 12:42:01 -05:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
2026-01-29 11:39:49 -05:00
|
|
|
// Get margins with button hint space for all orientations
|
2026-01-22 12:42:01 -05:00
|
|
|
int marginTop, marginRight, marginBottom, marginLeft;
|
|
|
|
|
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
|
|
|
|
|
|
2026-01-29 11:39:49 -05:00
|
|
|
const auto screenHeight = renderer.getScreenHeight();
|
|
|
|
|
|
2026-01-22 12:42:01 -05:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 11:39:49 -05:00
|
|
|
// Draw instruction text - always show, positioned just above the front button area
|
2026-01-28 15:57:31 -05:00
|
|
|
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
|
|
|
|
|
"Navigate with arrows, select with confirm");
|
2026-01-22 12:42:01 -05:00
|
|
|
|
2026-01-29 11:39:49 -05:00
|
|
|
// Draw button hints with proper left/right navigation labels
|
|
|
|
|
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< Prev", "Next >");
|
2026-01-22 12:42:01 -05:00
|
|
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
|
|
2026-01-29 11:39:49 -05:00
|
|
|
// Draw side button hints for up/down line navigation (no border, small font)
|
|
|
|
|
// Top physical button = Up (prev line), Bottom physical button = Down (next line)
|
|
|
|
|
const int lastLine = findLineForWordIndex(static_cast<int>(allWords.size()) - 1);
|
|
|
|
|
const char* sideTopHint = (currentLineIndex > 0) ? "UP" : "";
|
|
|
|
|
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
|
|
|
|
|
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
|
|
|
|
|
|
2026-01-22 12:42:01 -05:00
|
|
|
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
|
|
|
|
}
|