crosspoint-reader/src/activities/dictionary/EpubWordSelectionActivity.cpp
cottongin be8b02efd6
feat: merge PR #522 - add HalDisplay and HalGPIO abstraction layer
Cherry-picked upstream PR #522 (da4d3b5) with conflict resolution:
- Added new lib/hal/ files (HalDisplay, HalGPIO)
- Updated GfxRenderer to use HalDisplay, preserving base viewable margins
- Adopted PR #522's MappedInputManager lookup table implementation
- Updated main.cpp to use HAL while preserving custom Serial initialization
- Updated all EInkDisplay::RefreshMode references to HalDisplay::RefreshMode

This introduces a Hardware Abstraction Layer for display and GPIO,
enabling easier emulation and testing.
2026-01-30 22:49:52 -05:00

268 lines
8.5 KiB
C++

#include "EpubWordSelectionActivity.h"
#include <HalDisplay.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;
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
}
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;
const bool hasAlpha = std::any_of(wordText.begin(), wordText.end(),
[](char c) { return std::isalpha(static_cast<unsigned char>(c)); });
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();
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto screenHeight = renderer.getScreenHeight();
// 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 - always show, positioned just above the front button area
renderer.drawCenteredText(SMALL_FONT_ID, screenHeight - marginBottom - 10,
"Navigate with arrows, select with confirm");
// Draw button hints with proper left/right navigation labels
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Select", "< Prev", "Next >");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// 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
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}