mod: Phase 1 - bring forward mod-exclusive files with ActivityManager migration

Brings ~55 mod-exclusive files to the upstream-based mod/master-resync branch:

Activities (migrated to new ActivityManager pattern):
- Clock/Time: SetTimeActivity, SetTimezoneOffsetActivity, NtpSyncActivity
- Dictionary: DictionaryDefinitionActivity, DictionarySuggestionsActivity,
  DictionaryWordSelectActivity, LookedUpWordsActivity
- Bookmark: EpubReaderBookmarkSelectionActivity
- Book management: BookManageMenuActivity, EndOfBookMenuActivity
- OPDS: OpdsServerListActivity, OpdsSettingsActivity
- Utility: DirectoryPickerActivity, NumericStepperActivity

Utilities (unchanged):
- BookManager, BookSettings, BookmarkStore, BootNtpSync
- Dictionary, LookupHistory, TimeSync, OpdsServerStore

Libraries: PlaceholderCover, TableData, ChapterXPathIndexer
Scripts: inject_mod_version, generate_book_icon, preview_placeholder_cover
Docs: KOReader sync XPath mapping

Migration changes:
- ActivityWithSubactivity -> Activity base class
- Callback constructors -> finish()/setResult() pattern
- enterNewActivity() -> startActivityForResult()
- Activity::RenderLock&& -> RenderLock&&

These files won't compile yet - they reference mod settings and I18n
strings that will be added in subsequent phases.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-07 15:10:00 -05:00
parent 170cc25774
commit dfbc931c14
147 changed files with 112771 additions and 1 deletions

View File

@@ -0,0 +1,523 @@
#include "DictionaryDefinitionActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include "ActivityResult.h"
#include <cctype>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void DictionaryDefinitionActivity::onEnter() {
Activity::onEnter();
wrapText();
requestUpdate();
}
void DictionaryDefinitionActivity::onExit() { Activity::onExit(); }
// ---------------------------------------------------------------------------
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
// ---------------------------------------------------------------------------
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
return false;
}
// ---------------------------------------------------------------------------
// HTML entity decoder
// ---------------------------------------------------------------------------
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
// Named entities
if (entity == "amp") return "&";
if (entity == "lt") return "<";
if (entity == "gt") return ">";
if (entity == "quot") return "\"";
if (entity == "apos") return "'";
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
if (entity == "lsquo") return "\xE2\x80\x98";
if (entity == "rsquo") return "\xE2\x80\x99";
if (entity == "ldquo") return "\xE2\x80\x9C";
if (entity == "rdquo") return "\xE2\x80\x9D";
if (entity == "hellip") return "\xE2\x80\xA6";
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
// Numeric entities: &#123; or &#x1F;
if (!entity.empty() && entity[0] == '#') {
unsigned long cp = 0;
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
} else {
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
}
if (cp > 0 && cp < 0x80) {
return std::string(1, static_cast<char>(cp));
}
if (cp >= 0x80 && cp < 0x800) {
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 2);
}
if (cp >= 0x800 && cp < 0x10000) {
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 3);
}
if (cp >= 0x10000 && cp < 0x110000) {
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
return std::string(buf, 4);
}
}
return ""; // unknown entity — drop it
}
// ---------------------------------------------------------------------------
// HTML → TextAtom list
// ---------------------------------------------------------------------------
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
std::vector<TextAtom> atoms;
bool isBold = false;
bool isItalic = false;
bool inSvg = false;
int svgDepth = 0;
std::vector<ListState> listStack;
std::string currentWord;
auto currentStyle = [&]() -> EpdFontFamily::Style {
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
if (isBold) return EpdFontFamily::BOLD;
if (isItalic) return EpdFontFamily::ITALIC;
return EpdFontFamily::REGULAR;
};
auto flushWord = [&]() {
if (!currentWord.empty() && !inSvg) {
atoms.push_back({currentWord, currentStyle(), false, 0});
currentWord.clear();
}
};
auto indentPx = [&]() -> int {
// 15 pixels per nesting level (the first level has no extra indent)
int depth = static_cast<int>(listStack.size());
return (depth > 1) ? (depth - 1) * 15 : 0;
};
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
// that appears before the first tag in sametypesequence=h entries.
size_t i = 0;
{
size_t firstTag = html.find('<');
if (firstTag != std::string::npos) i = firstTag;
}
while (i < html.size()) {
// ------- HTML tag -------
if (html[i] == '<') {
flushWord();
size_t tagEnd = html.find('>', i);
if (tagEnd == std::string::npos) break;
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
// Extract tag name: first token, lowercased, trailing '/' stripped.
size_t space = tagContent.find(' ');
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
// --- SVG handling (skip all content inside <svg>…</svg>) ---
if (tagName == "svg") {
inSvg = true;
svgDepth = 1;
} else if (inSvg) {
if (tagName == "svg") {
svgDepth++;
} else if (tagName == "/svg") {
svgDepth--;
if (svgDepth <= 0) inSvg = false;
}
}
if (!inSvg) {
// --- Inline style tags ---
if (tagName == "b" || tagName == "strong") {
isBold = true;
} else if (tagName == "/b" || tagName == "/strong") {
isBold = false;
} else if (tagName == "i" || tagName == "em") {
isItalic = true;
} else if (tagName == "/i" || tagName == "/em") {
isItalic = false;
// --- Block-level tags → newlines ---
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// Headings get bold style applied to following text
if (tagName != "p") isBold = true;
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
isBold = false;
} else if (tagName == "br") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
// --- Separator between definition entries ---
} else if (tagName == "/html") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
isBold = false;
isItalic = false;
// Skip any raw text between </html> and the next tag — this is where
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
size_t nextTag = html.find('<', tagEnd + 1);
i = (nextTag != std::string::npos) ? nextTag : html.size();
continue;
// --- Lists ---
} else if (tagName == "ol") {
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
listStack.push_back({0, alpha});
} else if (tagName == "ul") {
listStack.push_back({0, false});
} else if (tagName == "/ol" || tagName == "/ul") {
if (!listStack.empty()) listStack.pop_back();
} else if (tagName == "li") {
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
if (!listStack.empty()) {
auto& ls = listStack.back();
ls.counter++;
std::string marker;
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
} else if (ls.isAlpha) {
marker = std::to_string(ls.counter) + ". ";
} else {
marker = std::to_string(ls.counter) + ". ";
}
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
} else {
// Unordered list or bare <li>
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
}
}
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
// their text content will still be emitted.
}
i = tagEnd + 1;
continue;
}
// Skip content inside SVG
if (inSvg) {
i++;
continue;
}
// ------- HTML entity -------
if (html[i] == '&') {
size_t semicolon = html.find(';', i);
if (semicolon != std::string::npos && semicolon - i < 16) {
std::string entity = html.substr(i + 1, semicolon - i - 1);
std::string decoded = decodeEntity(entity);
if (!decoded.empty()) {
// Treat decoded chars like normal text (could be space etc.)
for (char dc : decoded) {
if (dc == ' ') {
flushWord();
} else {
currentWord += dc;
}
}
}
i = semicolon + 1;
continue;
}
// Not a valid entity — emit '&' literally
currentWord += '&';
i++;
continue;
}
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
if (html[i] == '/' || html[i] == '[') {
char closeDelim = (html[i] == '/') ? '/' : ']';
size_t end = html.find(closeDelim, i + 1);
if (end != std::string::npos && end - i < 80) {
bool hasNonAscii = false;
for (size_t j = i + 1; j < end; j++) {
if (static_cast<unsigned char>(html[j]) > 127) {
hasNonAscii = true;
break;
}
}
if (hasNonAscii) {
flushWord();
i = end + 1; // skip entire IPA section including delimiters
continue;
}
}
// Not IPA — fall through to treat as regular character
}
// ------- Whitespace -------
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
flushWord();
i++;
continue;
}
// ------- Regular character (with non-renderable character filter) -------
{
unsigned char byte = static_cast<unsigned char>(html[i]);
if (byte < 0x80) {
// ASCII — always renderable
currentWord += html[i];
i++;
} else {
// Multi-byte UTF-8: decode codepoint and check if renderable
int seqLen = 1;
uint32_t cp = 0;
if ((byte & 0xE0) == 0xC0) {
seqLen = 2;
cp = byte & 0x1F;
} else if ((byte & 0xF0) == 0xE0) {
seqLen = 3;
cp = byte & 0x0F;
} else if ((byte & 0xF8) == 0xF0) {
seqLen = 4;
cp = byte & 0x07;
} else {
i++;
continue;
} // invalid start byte
if (i + static_cast<size_t>(seqLen) > html.size()) {
i++;
continue;
}
bool valid = true;
for (int j = 1; j < seqLen; j++) {
unsigned char cb = static_cast<unsigned char>(html[i + j]);
if ((cb & 0xC0) != 0x80) {
valid = false;
break;
}
cp = (cp << 6) | (cb & 0x3F);
}
if (valid && isRenderableCodepoint(cp)) {
for (int j = 0; j < seqLen; j++) {
currentWord += html[i + j];
}
}
// else: silently skip non-renderable character
i += valid ? seqLen : 1;
}
}
}
flushWord();
return atoms;
}
// ---------------------------------------------------------------------------
// Word-wrap the parsed HTML atoms into positioned line segments
// ---------------------------------------------------------------------------
void DictionaryDefinitionActivity::wrapText() {
wrappedLines.clear();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int screenWidth = renderer.getScreenWidth();
const int lineHeight = renderer.getLineHeight(readerFontId);
const int sidePadding = landscape ? 50 : 20;
constexpr int topArea = 50;
constexpr int bottomArea = 50;
const int maxWidth = screenWidth - 2 * sidePadding;
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
if (linesPerPage < 1) linesPerPage = 1;
auto atoms = parseHtml(definition);
std::vector<Segment> currentLine;
int currentX = 0;
int baseIndent = 0; // indent for continuation lines within the same block
for (const auto& atom : atoms) {
// ---- Newline directive ----
if (atom.isNewline) {
// Collapse multiple consecutive blank lines
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
// Already have a blank line; update indent but don't push another
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
baseIndent = atom.indent;
currentX = baseIndent;
continue;
}
// ---- Text word ----
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
int gap = (currentX > baseIndent) ? spaceWidth : 0;
// Wrap if this word won't fit
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
wrappedLines.push_back(std::move(currentLine));
currentLine.clear();
currentX = baseIndent;
gap = 0;
}
int16_t x = static_cast<int16_t>(currentX + gap);
currentLine.push_back({atom.text, x, atom.style});
currentX = x + wordWidth;
}
// Flush last line
if (!currentLine.empty()) {
wrappedLines.push_back(std::move(currentLine));
}
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
if (totalPages < 1) totalPages = 1;
}
void DictionaryDefinitionActivity::loop() {
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
mappedInput.wasReleased(MappedInputManager::Button::Right);
if (prevPage && currentPage > 0) {
currentPage--;
requestUpdate();
}
if (nextPage && currentPage < totalPages - 1) {
currentPage++;
requestUpdate();
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// When hasDoneButton: Done = exit to reader (not cancelled). Otherwise: same as Back.
ActivityResult r;
r.isCancelled = !hasDoneButton;
setResult(std::move(r));
finish();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Back = return to previous activity (cancelled)
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
return;
}
}
void DictionaryDefinitionActivity::render(RenderLock&&) {
renderer.clearScreen();
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
const int sidePadding = landscape ? 50 : 20;
constexpr int titleY = 10;
const int lineHeight = renderer.getLineHeight(readerFontId);
constexpr int bodyStartY = 50;
// Title: the word in bold (UI font)
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
// Separator line
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
// Body: styled definition lines
int startLine = currentPage * linesPerPage;
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
int y = bodyStartY + i * lineHeight;
const auto& line = wrappedLines[startLine + i];
for (const auto& seg : line) {
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
}
}
// Pagination indicator (bottom right)
if (totalPages > 1) {
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
renderer.getScreenHeight() - 50, pageInfo.c_str());
}
// Button hints (bottom face buttons)
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", hasDoneButton ? "Done" : "", "\xC2\xAB Page", "Page \xC2\xBB");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints (drawn in portrait coordinates for correct placement)
{
const auto origOrientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int portW = renderer.getScreenWidth();
constexpr int sideButtonWidth = 30;
constexpr int sideButtonHeight = 78;
constexpr int sideButtonGap = 5;
constexpr int sideTopY = 345;
constexpr int cornerRadius = 6;
const int sideX = portW - sideButtonWidth;
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
for (int i = 0; i < 2; i++) {
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
true, false, true);
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) {
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2,
truncated.c_str());
} else {
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2,
truncated.c_str());
}
}
renderer.setOrientation(origOrientation);
}
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
firstRender = false;
}

View File

@@ -0,0 +1,64 @@
#pragma once
#include <EpdFontFamily.h>
#include <string>
#include <vector>
#include "activities/Activity.h"
class DictionaryDefinitionActivity final : public Activity {
public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, bool hasDoneButton = false)
: Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword),
definition(definition),
readerFontId(readerFontId),
orientation(orientation),
hasDoneButton(hasDoneButton) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
struct Segment {
std::string text;
int16_t x;
EpdFontFamily::Style style;
};
// An intermediate token produced by the HTML parser before word-wrapping.
struct TextAtom {
std::string text;
EpdFontFamily::Style style;
bool isNewline;
int indent; // pixels to indent the new line (for nested lists)
};
// Tracks ordered/unordered list nesting during HTML parsing.
struct ListState {
int counter; // incremented per <li>, 0 = not yet used
bool isAlpha; // true for list-style-type: lower-alpha
};
std::string headword;
std::string definition;
int readerFontId;
uint8_t orientation;
bool hasDoneButton; // If true, Confirm shows "Done" and finishes with !isCancelled (exit to reader)
std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0;
int linesPerPage = 0;
int totalPages = 0;
bool firstRender = true;
std::vector<TextAtom> parseHtml(const std::string& html);
static std::string decodeEntity(const std::string& entity);
static bool isRenderableCodepoint(uint32_t cp);
void wrapText();
};

View File

@@ -0,0 +1,117 @@
#include "DictionarySuggestionsActivity.h"
#include <GfxRenderer.h>
#include "DictionaryDefinitionActivity.h"
#include "HalDisplay.h"
#include "MappedInputManager.h"
#include "RenderLock.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/Dictionary.h"
void DictionarySuggestionsActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void DictionarySuggestionsActivity::onExit() { Activity::onExit(); }
void DictionarySuggestionsActivity::loop() {
if (suggestions.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
}
return;
}
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(suggestions.size()));
requestUpdate();
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const std::string& selected = suggestions[selectedIndex];
std::string definition = Dictionary::lookup(selected);
if (definition.empty()) {
{
RenderLock lock(*this);
GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
vTaskDelay(1000 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
startActivityForResult(
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, selected, definition, readerFontId,
orientation, true),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
return;
}
}
void DictionarySuggestionsActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int leftPadding = contentX + metrics.contentSidePadding;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Header
GUI.drawHeader(
renderer,
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Did you mean?");
// Subtitle: the original word (manual, below header)
const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5;
std::string subtitle = "\"" + originalWord + "\" not found";
renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str());
// Suggestion list
const int listTop = subtitleY + 25;
const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
GUI.drawList(
renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex,
[this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr);
// Button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
class DictionarySuggestionsActivity final : public Activity {
public:
explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& originalWord, const std::vector<std::string>& suggestions,
int readerFontId, uint8_t orientation, const std::string& cachePath)
: Activity("DictionarySuggestions", renderer, mappedInput),
originalWord(originalWord),
suggestions(suggestions),
readerFontId(readerFontId),
orientation(orientation),
cachePath(cachePath) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
std::string originalWord;
std::vector<std::string> suggestions;
int readerFontId;
uint8_t orientation;
std::string cachePath;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
};

View File

@@ -0,0 +1,650 @@
#include "DictionaryWordSelectActivity.h"
#include <GfxRenderer.h>
#include <algorithm>
#include <climits>
#include "ActivityResult.h"
#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() {
Activity::onEnter();
extractWords();
mergeHyphenatedWords();
if (!rows.empty()) {
currentRow = static_cast<int>(rows.size()) / 3;
currentWordInRow = 0;
}
requestUpdate();
}
void DictionaryWordSelectActivity::onExit() { Activity::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<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;
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});
}
}
++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
}
// 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;
}
}
}
// 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() {
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
finish();
}
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()) {
{
RenderLock lock(*this);
GUI.drawPopup(renderer, "No word");
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
vTaskDelay(1000 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
Rect popupLayout;
{
RenderLock lock(*this);
popupLayout = GUI.drawPopup(renderer, "Looking up...");
}
bool cancelled = false;
std::string definition = Dictionary::lookup(
cleaned,
[this, &popupLayout](int percent) {
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()) {
startActivityForResult(
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, cleaned, definition, fontId, orientation,
true),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
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()) {
startActivityForResult(
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, stem, stemDef, fontId, orientation,
true),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
}
// Find similar words for suggestions
auto similar = Dictionary::findSimilar(cleaned, 6);
if (!similar.empty()) {
startActivityForResult(
std::make_unique<DictionarySuggestionsActivity>(renderer, mappedInput, cleaned, similar, fontId, orientation,
cachePath),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
{
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)) {
finish();
return;
}
if (changed) {
requestUpdate();
}
}
void DictionaryWordSelectActivity::render(RenderLock&&) {
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);
}

View File

@@ -0,0 +1,67 @@
#pragma once
#include <Epub/Page.h>
#include <memory>
#include <string>
#include <vector>
#include "activities/Activity.h"
class DictionaryWordSelectActivity final : public Activity {
public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation,
const std::string& nextPageFirstWord = "")
: Activity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)),
fontId(fontId),
marginLeft(marginLeft),
marginTop(marginTop),
cachePath(cachePath),
orientation(orientation),
nextPageFirstWord(nextPageFirstWord) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
struct WordInfo {
std::string text;
std::string lookupText;
int16_t screenX;
int16_t screenY;
int16_t width;
int16_t row;
int continuationIndex;
int continuationOf;
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
};
struct Row {
int16_t yPos;
std::vector<int> wordIndices;
};
std::unique_ptr<Page> page;
int fontId;
int marginLeft;
int marginTop;
std::string cachePath;
uint8_t orientation;
std::string nextPageFirstWord;
std::vector<WordInfo> words;
std::vector<Row> rows;
int currentRow = 0;
int currentWordInRow = 0;
bool isLandscape() const;
bool isInverted() const;
void extractWords();
void mergeHyphenatedWords();
void drawHints();
};

View File

@@ -0,0 +1,77 @@
#include "EndOfBookMenuActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "ActivityResult.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
void EndOfBookMenuActivity::buildMenuItems() {
menuItems.clear();
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
menuItems.push_back({Action::TABLE_OF_CONTENTS, StrId::STR_TABLE_OF_CONTENTS});
menuItems.push_back({Action::BACK_TO_BEGINNING, StrId::STR_BACK_TO_BEGINNING});
menuItems.push_back({Action::CLOSE_BOOK, StrId::STR_CLOSE_BOOK});
menuItems.push_back({Action::CLOSE_MENU, StrId::STR_CLOSE_MENU});
}
void EndOfBookMenuActivity::onEnter() {
Activity::onEnter();
selectedIndex = 0;
requestUpdate();
}
void EndOfBookMenuActivity::onExit() { Activity::onExit(); }
void EndOfBookMenuActivity::loop() {
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectedIndex < static_cast<int>(menuItems.size())) {
setResult(MenuResult{.action = static_cast<int>(menuItems[selectedIndex].action)});
finish();
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
return;
}
}
void EndOfBookMenuActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
auto metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_END_OF_BOOK));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(menuItems.size()), selectedIndex,
[this](int index) { return std::string(I18N.get(menuItems[index].labelId)); });
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,44 @@
#pragma once
#include <I18n.h>
#include <string>
#include <vector>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
class EndOfBookMenuActivity final : public Activity {
public:
enum class Action {
ARCHIVE,
DELETE,
TABLE_OF_CONTENTS,
BACK_TO_BEGINNING,
CLOSE_BOOK,
CLOSE_MENU,
};
explicit EndOfBookMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath)
: Activity("EndOfBookMenu", renderer, mappedInput), bookPath(bookPath) {
buildMenuItems();
}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
struct MenuItem {
Action action;
StrId labelId;
};
std::string bookPath;
std::vector<MenuItem> menuItems;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
void buildMenuItems();
};

View File

@@ -0,0 +1,223 @@
#include "EpubReaderBookmarkSelectionActivity.h"
#include <GfxRenderer.h>
#include "ActivityResult.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int startY = 60 + hintGutterHeight;
const int availableHeight = screenHeight - startY - lineHeight;
return std::max(1, availableHeight / lineHeight);
}
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
std::string label;
if (epub) {
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
label = epub->getTocItem(tocIndex).title;
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
} else {
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
}
if (!bookmark.snippet.empty()) {
label += " - " + bookmark.snippet;
}
return label;
}
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
return " - Page " + std::to_string(bookmark.pageNumber + 1);
}
void EpubReaderBookmarkSelectionActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void EpubReaderBookmarkSelectionActivity::onExit() { Activity::onExit(); }
void EpubReaderBookmarkSelectionActivity::loop() {
const int totalItems = getTotalItems();
if (totalItems == 0) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
}
return;
}
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
bookmarks[pendingDeleteIndex].pageNumber);
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
}
deleteConfirmMode = false;
requestUpdate();
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
requestUpdate();
}
return;
}
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectorIndex;
requestUpdate();
}
return;
}
const int pageItems = getPageItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex >= 0 && selectorIndex < totalItems) {
const auto& b = bookmarks[selectorIndex];
setResult(SyncResult{.spineIndex = b.spineIndex, .page = b.pageNumber});
finish();
} else {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult r;
r.isCancelled = true;
setResult(std::move(r));
finish();
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
}
void EpubReaderBookmarkSelectionActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
if (totalItems == 0) {
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
} else {
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
const std::string truncatedPrefix =
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
const std::string label = truncatedPrefix + suffix;
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
}
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
std::string msg = "Delete bookmark" + suffix + "?";
constexpr int margin = 15;
constexpr int popupY = 200;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
if (!bookmarks.empty()) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
deleteHint);
}
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <vector>
#include "../Activity.h"
#include "util/BookmarkStore.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarkSelectionActivity final : public Activity {
std::shared_ptr<Epub> epub;
std::vector<Bookmark> bookmarks;
std::string cachePath;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
int getPageItems() const;
int getTotalItems() const;
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
static std::string getPageSuffix(const Bookmark& bookmark);
public:
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, std::vector<Bookmark> bookmarks,
const std::string& cachePath)
: Activity("EpubReaderBookmarkSelection", renderer, mappedInput),
epub(epub),
bookmarks(std::move(bookmarks)),
cachePath(cachePath) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
};

View File

@@ -0,0 +1,300 @@
#include "LookedUpWordsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include "ActivityResult.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 LookedUpWordsActivity::onEnter() {
Activity::onEnter();
words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
// Append the "Delete Dictionary Cache" sentinel entry
words.push_back("\xE2\x80\x94 " + std::string(tr(STR_DELETE_DICT_CACHE)));
deleteDictCacheIndex = static_cast<int>(words.size()) - 1;
requestUpdate();
}
void LookedUpWordsActivity::onExit() { Activity::onExit(); }
void LookedUpWordsActivity::loop() {
// Empty list has only the sentinel entry; if even that's gone, just go back.
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
finish();
}
return;
}
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
if (deleteConfirmMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
// Ignore the release from the initial long press
ignoreNextConfirmRelease = false;
} else {
// Confirm delete
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
words.erase(words.begin() + pendingDeleteIndex);
// Adjust sentinel index since we removed an item before it
if (deleteDictCacheIndex > pendingDeleteIndex) {
deleteDictCacheIndex--;
}
if (selectedIndex >= static_cast<int>(words.size())) {
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
}
deleteConfirmMode = false;
requestUpdate();
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
deleteConfirmMode = false;
ignoreNextConfirmRelease = false;
requestUpdate();
}
return;
}
// Detect long press on Confirm to trigger delete (only for real word entries, not sentinel)
constexpr unsigned long DELETE_HOLD_MS = 700;
if (selectedIndex != deleteDictCacheIndex && mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectedIndex;
requestUpdate();
return;
}
const int totalItems = static_cast<int>(words.size());
const int pageItems = getPageItems();
buttonNavigator.onNextRelease([this, totalItems] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
requestUpdate();
});
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Consume stale release from long-press navigation into this activity
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
// Handle the "Delete Dictionary Cache" sentinel entry
if (selectedIndex == deleteDictCacheIndex) {
if (Dictionary::cacheExists()) {
Dictionary::deleteCache();
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
} else {
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
const std::string& headword = words[selectedIndex];
Rect popupLayout;
{
RenderLock lock(*this);
popupLayout = GUI.drawPopup(renderer, "Looking up...");
}
std::string definition = Dictionary::lookup(headword, [this, &popupLayout](int percent) {
RenderLock lock(*this);
GUI.fillPopupProgress(renderer, popupLayout, percent);
});
if (!definition.empty()) {
startActivityForResult(
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, headword, definition, readerFontId,
orientation, true),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
// Try stem variants
auto stems = Dictionary::getStemVariants(headword);
for (const auto& stem : stems) {
std::string stemDef = Dictionary::lookup(stem);
if (!stemDef.empty()) {
startActivityForResult(
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, stem, stemDef, readerFontId,
orientation, true),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
}
// Show similar word suggestions
auto similar = Dictionary::findSimilar(headword, 6);
if (!similar.empty()) {
startActivityForResult(
std::make_unique<DictionarySuggestionsActivity>(renderer, mappedInput, headword, similar, readerFontId,
orientation, cachePath),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
setResult(result);
finish();
} else {
requestUpdate();
}
});
return;
}
{
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)) {
finish();
return;
}
}
int LookedUpWordsActivity::getPageItems() const {
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight =
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
return std::max(1, contentHeight / metrics.listRowHeight);
}
void LookedUpWordsActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto orient = renderer.getOrientation();
const auto metrics = UITheme::getInstance().getMetrics();
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int pageWidth = renderer.getScreenWidth();
const int pageHeight = renderer.getScreenHeight();
// Header
GUI.drawHeader(
renderer,
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
"Lookup History");
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// The list always has at least the sentinel entry
const bool hasRealWords = (deleteDictCacheIndex > 0);
if (words.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
} else {
GUI.drawList(
renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
[this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
}
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
// Draw delete confirmation overlay
const std::string& word = words[pendingDeleteIndex];
std::string displayWord = word;
if (displayWord.size() > 20) {
displayWord.erase(17);
displayWord += "...";
}
std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15;
const int popupY = 200 + hintGutterHeight;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = popupY + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
// Button hints for delete mode
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
// "Hold select to delete" hint above button hints (only when real words exist)
if (hasRealWords) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
renderer.drawText(SMALL_FONT_ID, hintX,
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
deleteHint);
}
// Normal button hints
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer();
}

View File

@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "util/ButtonNavigator.h"
class LookedUpWordsActivity final : public Activity {
public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
int readerFontId, uint8_t orientation, bool initialSkipRelease = false)
: Activity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
ignoreNextConfirmRelease(initialSkipRelease) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
std::string cachePath;
int readerFontId;
uint8_t orientation;
std::vector<std::string> words;
int selectedIndex = 0;
ButtonNavigator buttonNavigator;
// Delete confirmation state
bool deleteConfirmMode = false;
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
// Sentinel index: the "Delete Dictionary Cache" entry at the end of the list.
// -1 if not present (shouldn't happen when dictionary exists).
int deleteDictCacheIndex = -1;
int getPageItems() const;
};