2026-02-12 19:36:14 -05:00
|
|
|
#include "DictionaryWordSelectActivity.h"
|
|
|
|
|
|
|
|
|
|
#include <GfxRenderer.h>
|
|
|
|
|
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <climits>
|
|
|
|
|
|
|
|
|
|
#include "CrossPointSettings.h"
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
#include "DictionaryDefinitionActivity.h"
|
|
|
|
|
#include "DictionarySuggestionsActivity.h"
|
2026-02-12 19:36:14 -05:00
|
|
|
#include "MappedInputManager.h"
|
|
|
|
|
#include "components/UITheme.h"
|
|
|
|
|
#include "fontIds.h"
|
|
|
|
|
#include "util/Dictionary.h"
|
|
|
|
|
#include "util/LookupHistory.h"
|
|
|
|
|
|
|
|
|
|
void DictionaryWordSelectActivity::onEnter() {
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
ActivityWithSubactivity::onEnter();
|
2026-02-12 19:36:14 -05:00
|
|
|
extractWords();
|
|
|
|
|
mergeHyphenatedWords();
|
|
|
|
|
if (!rows.empty()) {
|
|
|
|
|
currentRow = static_cast<int>(rows.size()) / 3;
|
|
|
|
|
currentWordInRow = 0;
|
|
|
|
|
}
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
2026-02-12 19:36:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void DictionaryWordSelectActivity::onExit() {
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
ActivityWithSubactivity::onExit();
|
2026-02-12 19:36:14 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<const PageLine*>(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<int16_t>(*xIt) + marginLeft;
|
|
|
|
|
int16_t screenY = line->yPos + marginTop;
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
const std::string& wordText = *wordIt;
|
|
|
|
|
|
|
|
|
|
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
|
|
|
|
|
std::vector<size_t> splitStarts;
|
|
|
|
|
size_t partStart = 0;
|
|
|
|
|
for (size_t i = 0; i < wordText.size();) {
|
|
|
|
|
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
|
|
|
|
|
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
|
|
|
|
|
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(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<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
|
|
|
|
|
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
|
|
|
|
|
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
|
|
|
|
|
static_cast<uint8_t>(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<int16_t>(screenX + offsetX), screenY, partWidth, 0});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-12 19:36:14 -05:00
|
|
|
|
|
|
|
|
++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<int16_t>(rows.size() - 1);
|
|
|
|
|
rows.back().wordIndices.push_back(static_cast<int>(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<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
|
|
|
|
static_cast<uint8_t>(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<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
|
|
|
|
static_cast<uint8_t>(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
|
|
|
|
|
}
|
|
|
|
|
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
// 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<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
|
|
|
|
static_cast<uint8_t>(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<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
|
|
|
|
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
|
|
|
|
|
firstPart.erase(firstPart.size() - 2);
|
|
|
|
|
}
|
|
|
|
|
std::string merged = firstPart + nextPageFirstWord;
|
|
|
|
|
words[lastWordIdx].lookupText = merged;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 19:36:14 -05:00
|
|
|
// 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() {
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
// Delegate to subactivity (definition/suggestions screen) if active
|
|
|
|
|
if (subActivity) {
|
|
|
|
|
subActivity->loop();
|
|
|
|
|
if (pendingBackFromDef) {
|
|
|
|
|
pendingBackFromDef = false;
|
|
|
|
|
exitActivity();
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
}
|
|
|
|
|
if (pendingExitToReader) {
|
|
|
|
|
pendingExitToReader = false;
|
|
|
|
|
exitActivity();
|
|
|
|
|
onBack();
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 19:36:14 -05:00
|
|
|
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<int>(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<int>(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<int>(rows[currentRow].wordIndices.size()) - 1;
|
|
|
|
|
}
|
|
|
|
|
changed = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Move to next word (wrap to start of next row)
|
|
|
|
|
if (wordNextPressed) {
|
|
|
|
|
if (currentWordInRow < static_cast<int>(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()) {
|
2026-02-16 13:22:40 -05:00
|
|
|
{
|
|
|
|
|
Activity::RenderLock lock(*this);
|
|
|
|
|
GUI.drawPopup(renderer, "No word");
|
|
|
|
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
|
|
|
|
}
|
2026-02-12 19:36:14 -05:00
|
|
|
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
2026-02-12 19:36:14 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 13:22:40 -05:00
|
|
|
Rect popupLayout;
|
|
|
|
|
{
|
|
|
|
|
Activity::RenderLock lock(*this);
|
|
|
|
|
popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
|
|
|
|
}
|
2026-02-12 19:36:14 -05:00
|
|
|
|
|
|
|
|
bool cancelled = false;
|
|
|
|
|
std::string definition = Dictionary::lookup(
|
|
|
|
|
cleaned,
|
|
|
|
|
[this, &popupLayout](int percent) {
|
2026-02-16 13:22:40 -05:00
|
|
|
Activity::RenderLock lock(*this);
|
2026-02-12 19:36:14 -05:00
|
|
|
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
|
|
|
|
},
|
|
|
|
|
[this, &cancelled]() -> bool {
|
|
|
|
|
mappedInput.update();
|
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (cancelled) {
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
2026-02-12 19:36:14 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
LookupHistory::addWord(cachePath, cleaned);
|
|
|
|
|
|
|
|
|
|
if (!definition.empty()) {
|
|
|
|
|
enterNewActivity(new DictionaryDefinitionActivity(
|
|
|
|
|
renderer, mappedInput, cleaned, definition, fontId, orientation,
|
|
|
|
|
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
2026-02-12 19:36:14 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 13:22:40 -05:00
|
|
|
{
|
|
|
|
|
Activity::RenderLock lock(*this);
|
|
|
|
|
GUI.drawPopup(renderer, "Not found");
|
|
|
|
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
|
|
|
|
}
|
feat: Integrate PR #857 dictionary intelligence and sub-activity refactor
Pull in the full feature update from PR #857 while preserving fork
advantages (HTML parsing, custom drawHints, PageForward/PageBack,
cache management, stardictCmp, /.dictionary/ paths).
- Add morphological stemming (getStemVariants), Levenshtein edit
distance, and fuzzy matching (findSimilar) to Dictionary
- Create DictionarySuggestionsActivity for "Did you mean?" flow
- Add onDone callback to DictionaryDefinitionActivity for direct
exit-to-reader via "Done" button
- Refactor DictionaryWordSelectActivity to ActivityWithSubactivity
with cascading lookup (exact → stems → suggestions → not found),
en-dash/em-dash splitting, and cross-page hyphenation
- Refactor LookedUpWordsActivity with reverse-chronological order,
inline cascading lookup, UITheme-aware rendering, and sub-activities
- Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 20:50:03 -05:00
|
|
|
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
2026-02-12 19:36:14 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
|
|
|
onBack();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (changed) {
|
2026-02-16 13:22:40 -05:00
|
|
|
requestUpdate();
|
2026-02-12 19:36:14 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 13:22:40 -05:00
|
|
|
void DictionaryWordSelectActivity::render(Activity::RenderLock&&) {
|
2026-02-12 19:36:14 -05:00
|
|
|
renderer.clearScreen();
|
|
|
|
|
|
|
|
|
|
// Render the page content
|
|
|
|
|
page->render(renderer, fontId, marginLeft, marginTop);
|
|
|
|
|
|
|
|
|
|
if (!words.empty() && currentRow < static_cast<int>(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<int>(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);
|
|
|
|
|
}
|