feat: Slim dictionary (#2583)
Co-authored-by: Ryan Hitchman <hitchmanr@gmail.com> Co-authored-by: Kurtis Grant <kurtis.b.grant@gmail.com> Co-authored-by: kemonine <kemonine@kemonine.info> Co-authored-by: DustinHu <hu.dustin@gmail.com> Co-authored-by: Justin Mitchell <justin@jmitch.com>
This commit is contained in:
co-authored by
Ryan Hitchman
Kurtis Grant
kemonine
DustinHu
Justin Mitchell
parent
5ba1d5747f
commit
b1d1d757ba
@@ -0,0 +1,237 @@
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/HtmlToPlainText.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Longest measurable/drawable span. Wrapped lines stay under the screen width
|
||||
// (far below this); only pathological unbreakable tokens are split at this cap.
|
||||
constexpr size_t MAX_LINE_BYTES = 191;
|
||||
|
||||
// Body text left/right inset, matching the reader's default feel.
|
||||
constexpr int SIDE_PADDING = 20;
|
||||
|
||||
} // namespace
|
||||
|
||||
void DictionaryDefinitionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
// Normalize StarDict multi-type separators so the wrap loop and the
|
||||
// C-string font APIs below both see the whole definition.
|
||||
std::replace(definition.begin(), definition.end(), '\0', '\n');
|
||||
definition = htmlToPlainText(definition);
|
||||
wrapText();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int DictionaryDefinitionActivity::measureSpan(const int fontId, const char* text, size_t len) const {
|
||||
char buf[MAX_LINE_BYTES + 1];
|
||||
len = std::min(len, MAX_LINE_BYTES);
|
||||
memcpy(buf, text, len);
|
||||
buf[len] = '\0';
|
||||
return renderer.getTextAdvanceX(fontId, buf, EpdFontFamily::REGULAR);
|
||||
}
|
||||
|
||||
// Greedy word-wrap of `definition` into byte spans. '\n' breaks lines (blank
|
||||
// lines survive as paragraph spacing; NULs from multi-type StarDict entries
|
||||
// were normalized to newlines in onEnter); '\r' is dropped by treating it as
|
||||
// a space at a token edge.
|
||||
void DictionaryDefinitionActivity::wrapText() {
|
||||
lines.clear();
|
||||
lines.reserve(definition.size() / 32 + 8);
|
||||
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
// SD-card fonts: merge every definition codepoint into the persistent
|
||||
// advance table up front. Otherwise each unseen codepoint measured below
|
||||
// falls back to an on-demand glyph load from SD (8-slot overflow ring).
|
||||
renderer.ensureSdCardFontReady(fontId, definition.c_str(), 0x01 /* REGULAR */);
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscape = orientation == GfxRenderer::Orientation::LandscapeClockwise ||
|
||||
orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
|
||||
const int maxWidth = renderer.getScreenWidth() - hintGutterWidth - 2 * SIDE_PADDING;
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR);
|
||||
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
const int topArea = (isInverted ? metrics.buttonHintsHeight : 0) + metrics.topPadding + metrics.headerHeight;
|
||||
const int bottomArea = metrics.buttonHintsHeight + metrics.verticalSpacing;
|
||||
linesPerPage = std::max(1, (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight);
|
||||
|
||||
const char* text = definition.c_str();
|
||||
const uint32_t n = static_cast<uint32_t>(definition.size());
|
||||
uint32_t lineStart = 0;
|
||||
uint32_t lineEnd = 0; // one past the last token byte on the current line
|
||||
int lineWidth = 0;
|
||||
|
||||
const auto flushLine = [&](uint32_t nextStart) {
|
||||
lines.push_back({lineStart, static_cast<uint16_t>(lineEnd - lineStart)});
|
||||
lineStart = nextStart;
|
||||
lineEnd = nextStart;
|
||||
lineWidth = 0;
|
||||
};
|
||||
|
||||
uint32_t i = 0;
|
||||
while (i < n) {
|
||||
const char c = text[i];
|
||||
if (c == '\n' || c == '\0') {
|
||||
flushLine(i + 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == ' ' || c == '\t' || c == '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Token: run of non-whitespace bytes, capped at the measure buffer.
|
||||
const uint32_t tokenStart = i;
|
||||
while (i < n && text[i] != ' ' && text[i] != '\t' && text[i] != '\r' && text[i] != '\n' && text[i] != '\0' &&
|
||||
i - tokenStart < MAX_LINE_BYTES) {
|
||||
i++;
|
||||
}
|
||||
// If the byte cap cut the token mid-UTF-8-sequence, back off to the last
|
||||
// complete codepoint so measure/draw never see a partial sequence. A
|
||||
// natural stop lands on whitespace or the terminating NUL, never on a
|
||||
// continuation byte, so this is a no-op there.
|
||||
while (i - tokenStart > 1 && (text[i] & 0xC0) == 0x80) i--;
|
||||
const uint32_t tokenLen = i - tokenStart;
|
||||
const int tokenWidth = measureSpan(fontId, text + tokenStart, tokenLen);
|
||||
|
||||
if (lineEnd == lineStart) {
|
||||
lineStart = tokenStart;
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth = tokenWidth;
|
||||
} else if (lineWidth + spaceWidth + tokenWidth <= maxWidth &&
|
||||
tokenStart + tokenLen - lineStart <= UINT16_MAX) { // span len must fit Line::len
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth += spaceWidth + tokenWidth;
|
||||
} else {
|
||||
flushLine(tokenStart);
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth = tokenWidth;
|
||||
}
|
||||
|
||||
// An unbreakable token wider than the screen is now alone on the line
|
||||
// (any previous content was flushed above): split it at the widest
|
||||
// fitting UTF-8 boundary and carry the remainder forward.
|
||||
while (lineWidth > maxWidth && lineEnd - lineStart > 1) {
|
||||
const uint32_t len = lineEnd - lineStart;
|
||||
uint32_t lastFit = 0;
|
||||
for (uint32_t f = 1; f <= len; f++) {
|
||||
if (f == len || (text[lineStart + f] & 0xC0) != 0x80) { // codepoint boundary
|
||||
if (measureSpan(fontId, text + lineStart, f) > maxWidth) break;
|
||||
lastFit = f;
|
||||
}
|
||||
}
|
||||
if (lastFit == 0) {
|
||||
// Even a single over-wide glyph must make progress; consume its whole
|
||||
// UTF-8 sequence rather than splitting it into invalid fragments.
|
||||
lastFit = 1;
|
||||
while (lastFit < len && (text[lineStart + lastFit] & 0xC0) == 0x80) lastFit++;
|
||||
}
|
||||
const uint32_t rest = lineStart + lastFit;
|
||||
lineEnd = rest;
|
||||
flushLine(rest);
|
||||
lineEnd = rest + (len - lastFit);
|
||||
lineWidth = measureSpan(fontId, text + lineStart, lineEnd - lineStart);
|
||||
}
|
||||
}
|
||||
if (lineEnd > lineStart) flushLine(n);
|
||||
|
||||
// Trim trailing blank lines so the last page is not empty padding.
|
||||
while (!lines.empty() && lines.back().len == 0) lines.pop_back();
|
||||
|
||||
totalPages = std::max(1, (static_cast<int>(lines.size()) + linesPerPage - 1) / linesPerPage);
|
||||
currentPage = 0;
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
if (currentPage + 1 < totalPages) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draws the current page's line spans (copied into a stack buffer for NUL
|
||||
// termination). Called twice per render: once in font-cache scan mode, once
|
||||
// for the real paint.
|
||||
void DictionaryDefinitionActivity::drawBody(const int fontId, const int x, const int startY) const {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
char buf[MAX_LINE_BYTES + 1];
|
||||
const int firstLine = currentPage * linesPerPage;
|
||||
const int lastLine = std::min(firstLine + linesPerPage, static_cast<int>(lines.size()));
|
||||
for (int i = firstLine; i < lastLine; i++) {
|
||||
if (lines[i].len == 0) continue;
|
||||
const size_t len = std::min(static_cast<size_t>(lines[i].len), MAX_LINE_BYTES);
|
||||
memcpy(buf, definition.c_str() + lines[i].start, len);
|
||||
buf[len] = '\0';
|
||||
renderer.drawText(fontId, x, startY + (i - firstLine) * lineHeight, buf);
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isInverted ? metrics.buttonHintsHeight : 0;
|
||||
|
||||
// Header: matched headword left, page counter right.
|
||||
const int headerY = contentY + metrics.topPadding + 10;
|
||||
renderer.drawText(UI_12_FONT_ID, contentX + SIDE_PADDING, headerY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (totalPages > 1) {
|
||||
char counter[16];
|
||||
snprintf(counter, sizeof(counter), "%d/%d", currentPage + 1, totalPages);
|
||||
const int counterWidth = renderer.getTextWidth(UI_10_FONT_ID, counter);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - SIDE_PADDING - counterWidth, headerY, counter);
|
||||
}
|
||||
|
||||
// Body: two-pass draw inside a prewarm scope (same pattern as the reader's
|
||||
// renderContents) so SD-card font glyphs load from SD in one batch instead
|
||||
// of one on-demand overflow read per character on every page turn.
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
const int bodyStartY = contentY + metrics.topPadding + metrics.headerHeight;
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY); // scan pass: records codepoints only
|
||||
scope.endScanAndPrewarm();
|
||||
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY);
|
||||
|
||||
const auto labels =
|
||||
mappedInput.mapLabels(tr(STR_BACK), "", (currentPage > 0 ? "<" : ""), (currentPage + 1 < totalPages ? ">" : ""));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// Paged plain-text viewer for one dictionary definition. The definition is
|
||||
// word-wrapped once on entry; each page renders spans of the original string,
|
||||
// so no per-line copies are held.
|
||||
class DictionaryDefinitionActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string headword,
|
||||
std::string definition)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(std::move(headword)),
|
||||
definition(std::move(definition)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// One wrapped display line: a byte span of `definition`. Wrapping keeps
|
||||
// lines under the screen width, so uint16_t length is ample.
|
||||
struct Line {
|
||||
uint32_t start;
|
||||
uint16_t len;
|
||||
};
|
||||
|
||||
void wrapText();
|
||||
int measureSpan(int fontId, const char* text, size_t len) const;
|
||||
void drawBody(int fontId, int x, int startY) const;
|
||||
|
||||
const std::string headword;
|
||||
// Not const: onEnter() normalizes embedded NULs (StarDict multi-type
|
||||
// separators) to newlines so C-string APIs see the whole text.
|
||||
std::string definition;
|
||||
std::vector<Line> lines;
|
||||
int currentPage = 0;
|
||||
int totalPages = 1;
|
||||
int linesPerPage = 1;
|
||||
ButtonNavigator buttonNavigator;
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Memory.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <climits>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr unsigned long POPUP_DURATION_MS = 1500;
|
||||
|
||||
// A token is selectable when it has an ASCII alphanumeric or a non-ASCII
|
||||
// codepoint outside U+2000-U+206F (dashes, bullets and other General
|
||||
// Punctuation that appear as standalone tokens are not words).
|
||||
bool isSelectableToken(const char* text) {
|
||||
for (const uint8_t* p = reinterpret_cast<const uint8_t*>(text); *p != 0; p++) {
|
||||
if (*p < 0x80) {
|
||||
if (std::isalnum(*p)) return true;
|
||||
} else if (*p == 0xE2 && (p[1] == 0x80 || p[1] == 0x81)) {
|
||||
if (p[2] == 0) break; // truncated sequence: skipping would step past the NUL
|
||||
p += 2; // skip the 3-byte General Punctuation codepoint
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void indexBuildYield(void*) { vTaskDelay(1); }
|
||||
|
||||
} // namespace
|
||||
|
||||
void DictionaryWordSelectActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
fontId = SETTINGS.getReaderFontId();
|
||||
lineHeight = renderer.getLineHeight(fontId);
|
||||
// No null check: a failed allocation just disables the differential
|
||||
// fast path (drawHighlightWithSnapshot skips the read), keeping the
|
||||
// full-repaint path as the fallback.
|
||||
snapshot = makeUniqueNoThrow<uint8_t[]>(SNAPSHOT_CAPACITY);
|
||||
extractWords();
|
||||
// Start on the middle row's word nearest mid-screen instead of top-left:
|
||||
// any word on the page is then at most half a page of moves away.
|
||||
if (!words.empty()) {
|
||||
const int initial = closestInRow(rowCount / 2, renderer.getScreenWidth() / 2);
|
||||
if (initial >= 0) selected = initial;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::extractWords() {
|
||||
words.clear();
|
||||
words.reserve(128);
|
||||
rowCount = 0;
|
||||
|
||||
// Single walk: collect the selectable words while accumulating their text
|
||||
// and styles (~2KB transient string, freed on return). Widths are measured
|
||||
// afterwards: merging the page's codepoints into the SD font's persistent
|
||||
// advance table first keeps getTextAdvanceX on the in-RAM path instead of
|
||||
// loading glyphs from SD one overflow slot at a time.
|
||||
std::string pageText;
|
||||
pageText.reserve(2048);
|
||||
uint8_t styleMask = 0;
|
||||
|
||||
for (const auto& element : page->elements) {
|
||||
if (element->getTag() != TAG_PageLine) continue;
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
const auto& block = line->getBlock();
|
||||
if (!block || !block->valid()) continue;
|
||||
|
||||
bool rowHasWords = false;
|
||||
for (uint16_t i = 0; i < block->wordCount(); i++) {
|
||||
const char* text = block->wordText(i);
|
||||
if (!isSelectableToken(text)) continue;
|
||||
|
||||
WordBox box;
|
||||
box.x = static_cast<int16_t>(line->xPos + block->wordXpos(i) + marginLeft);
|
||||
box.y = static_cast<int16_t>(line->yPos + marginTop);
|
||||
box.style = block->wordStyle(i);
|
||||
box.width = 0; // measured below, once the advance table is ready
|
||||
box.row = rowCount;
|
||||
box.text = text;
|
||||
words.push_back(box);
|
||||
rowHasWords = true;
|
||||
|
||||
pageText.append(text);
|
||||
pageText.push_back(' ');
|
||||
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(box.style) & 0x03));
|
||||
}
|
||||
if (rowHasWords) rowCount++;
|
||||
}
|
||||
|
||||
if (styleMask == 0) styleMask = 0x01; // REGULAR
|
||||
renderer.ensureSdCardFontReady(fontId, pageText.c_str(), styleMask);
|
||||
for (auto& word : words) {
|
||||
word.width = static_cast<int16_t>(renderer.getTextAdvanceX(fontId, word.text, word.style));
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the word in `row` whose horizontal center is closest to centerX;
|
||||
// -1 when the row has no words.
|
||||
int DictionaryWordSelectActivity::closestInRow(const uint16_t row, const int centerX) const {
|
||||
int best = -1;
|
||||
int bestDistance = INT_MAX;
|
||||
for (int i = 0; i < static_cast<int>(words.size()); i++) {
|
||||
if (words[i].row != row) continue;
|
||||
const int distance = std::abs(words[i].x + words[i].width / 2 - centerX);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::moveVertical(const int direction) {
|
||||
const WordBox& current = words[selected];
|
||||
const int targetRow = static_cast<int>(current.row) + direction;
|
||||
if (targetRow < 0 || targetRow >= static_cast<int>(rowCount)) return;
|
||||
|
||||
const int best = closestInRow(static_cast<uint16_t>(targetRow), current.x + current.width / 2);
|
||||
if (best >= 0 && best != selected) {
|
||||
selected = best;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::performLookup() {
|
||||
popup = Popup::Busy;
|
||||
if (!dictOpenAttempted) {
|
||||
dictOpenAttempted = true;
|
||||
dictOpenOk = dict.open(SETTINGS.dictionaryName);
|
||||
}
|
||||
const bool indexing = dictOpenOk && dict.needsIndex();
|
||||
popupMsg = indexing ? StrId::STR_DICT_INDEXING : StrId::STR_DICT_LOOKING_UP;
|
||||
requestUpdateAndWait(); // paint the page + busy popup before blocking on SD
|
||||
|
||||
bool ok = dictOpenOk;
|
||||
if (ok && indexing) ok = dict.buildIndex(&indexBuildYield);
|
||||
|
||||
std::string definition;
|
||||
std::string headword;
|
||||
const bool found = ok && dict.lookup(words[selected].text, definition, headword);
|
||||
|
||||
if (found) {
|
||||
popup = Popup::None;
|
||||
startActivityForResult(std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, std::move(headword),
|
||||
std::move(definition)),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
return;
|
||||
}
|
||||
popup = ok ? Popup::NotFound : Popup::Error;
|
||||
popupMsg = ok ? StrId::STR_DICT_NOT_FOUND : StrId::STR_DICT_ERROR;
|
||||
popupTime = millis();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::loop() {
|
||||
if (popup == Popup::NotFound || popup == Popup::Error) {
|
||||
if (millis() - popupTime >= POPUP_DURATION_MS) {
|
||||
popup = Popup::None;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) confirmPressSeen = true;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && confirmPressSeen && !words.empty()) {
|
||||
performLookup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (words.empty()) return;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left) && selected > 0) {
|
||||
selected--;
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Right) &&
|
||||
selected + 1 < static_cast<int>(words.size())) {
|
||||
selected++;
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
moveVertical(-1);
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
moveVertical(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Saves the pixels under words[selected]'s highlight box, then draws the
|
||||
// highlight over them. Returns false when the pixels could not be saved
|
||||
// (no buffer / oversize box) — the highlight is drawn regardless, but the
|
||||
// next cursor move must do a full repaint.
|
||||
bool DictionaryWordSelectActivity::drawHighlightWithSnapshot() {
|
||||
const WordBox& word = words[selected];
|
||||
int hx = word.x - 2;
|
||||
int hy = word.y - 2;
|
||||
int hw = word.width + 4;
|
||||
int hh = lineHeight + 4;
|
||||
// Clamp to the panel so save, draw and restore all use the same box.
|
||||
if (hx < 0) {
|
||||
hw += hx;
|
||||
hx = 0;
|
||||
}
|
||||
if (hy < 0) {
|
||||
hh += hy;
|
||||
hy = 0;
|
||||
}
|
||||
|
||||
bool saved = false;
|
||||
if (snapshot && hw > 0 && hh > 0) {
|
||||
saved = renderer.readFramebufferRegion(hx, hy, hw, hh, snapshot.get(), SNAPSHOT_CAPACITY) > 0;
|
||||
}
|
||||
snapshotX = static_cast<int16_t>(hx);
|
||||
snapshotY = static_cast<int16_t>(hy);
|
||||
snapshotW = static_cast<int16_t>(hw);
|
||||
snapshotH = static_cast<int16_t>(hh);
|
||||
snapshotIdx = saved ? selected : -1;
|
||||
|
||||
renderer.fillRect(hx, hy, hw, hh, true);
|
||||
renderer.drawText(fontId, word.x, word.y, word.text, false, word.style);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Front-button bar (Back/Confirm/Left/Right). Drawn last on every repaint
|
||||
// path, including the differential highlight-only path, so it always ends
|
||||
// up as the top layer even when a highlighted word's box falls under a
|
||||
// hint's screen area. No side-button hints: Up/Down row jump has no spare
|
||||
// screen area on this page (it reuses the reader's full-bleed layout), and
|
||||
// a hint box there would hide text instead of sitting in a reserved gutter.
|
||||
void DictionaryWordSelectActivity::drawHints() const {
|
||||
// No selectable word on this page: Confirm/Left/Right are all no-ops
|
||||
// (guarded by words.empty() in loop()/performLookup), so only Back does
|
||||
// anything and only Back is hinted.
|
||||
if (words.empty()) {
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
return;
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_LOOKUP), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::render(RenderLock&&) {
|
||||
// Differential fast path: only the highlight moved and the framebuffer
|
||||
// still holds a clean page (no popup or sub-activity since the last full
|
||||
// repaint). Restore the pixels under the old highlight, draw the new one,
|
||||
// and push — skipping the two-pass page render entirely.
|
||||
if (popup == Popup::None && snapshotIdx >= 0 && !words.empty() && selected != snapshotIdx) {
|
||||
renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get());
|
||||
// The full path's PrewarmScope cleared the glyph cache on exit; batch-load
|
||||
// just the highlighted word's glyphs before drawing them white-on-black.
|
||||
renderer.getFontCacheManager()->prewarmCache(
|
||||
fontId, words[selected].text, static_cast<uint8_t>(1u << (static_cast<uint8_t>(words[selected].style) & 0x03)));
|
||||
if (drawHighlightWithSnapshot()) {
|
||||
drawHints();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
return;
|
||||
}
|
||||
// Snapshot failed (oversize box) — fall through to a full repaint.
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Same prewarm-scan-then-render pass the reader uses, so SD-card fonts hit
|
||||
// the in-RAM glyph cache during the real draw.
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
scope.endScanAndPrewarm();
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
|
||||
if (!words.empty()) {
|
||||
drawHighlightWithSnapshot();
|
||||
}
|
||||
|
||||
drawHints();
|
||||
|
||||
if (popup != Popup::None) {
|
||||
// The popup overdraws the page, so the snapshot no longer matches the
|
||||
// framebuffer — force the next render onto the full-repaint path.
|
||||
snapshotIdx = -1;
|
||||
// drawPopup overlays the framebuffer and refreshes the display itself.
|
||||
// I18N.get directly: tr() only accepts literal key names.
|
||||
GUI.drawPopup(renderer, I18N.get(popupMsg));
|
||||
return;
|
||||
}
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <Epub/Page.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
// Button-driven word selection over the current reader page: Left/Right step
|
||||
// through words in reading order, Up/Down jump rows, Confirm looks the word up
|
||||
// and opens DictionaryDefinitionActivity, Back returns to the reader.
|
||||
class DictionaryWordSelectActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::unique_ptr<Page> page, int marginLeft, int marginTop)
|
||||
: Activity("DictionaryWordSelect", renderer, mappedInput),
|
||||
page(std::move(page)),
|
||||
marginLeft(marginLeft),
|
||||
marginTop(marginTop) {}
|
||||
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// Screen box of one selectable word. `text` points into the owned Page's
|
||||
// TextBlock arena (NUL-terminated), valid for this activity's lifetime.
|
||||
struct WordBox {
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
int16_t width;
|
||||
uint16_t row;
|
||||
const char* text;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
enum class Popup : uint8_t { None, Busy, NotFound, Error };
|
||||
|
||||
void extractWords();
|
||||
int closestInRow(uint16_t row, int centerX) const;
|
||||
void moveVertical(int direction);
|
||||
void performLookup();
|
||||
bool drawHighlightWithSnapshot();
|
||||
void drawHints() const;
|
||||
|
||||
std::unique_ptr<Page> page;
|
||||
const int marginLeft;
|
||||
const int marginTop;
|
||||
int fontId = 0;
|
||||
int lineHeight = 0;
|
||||
|
||||
std::vector<WordBox> words;
|
||||
int selected = 0;
|
||||
uint16_t rowCount = 0;
|
||||
|
||||
Dictionary dict;
|
||||
bool dictOpenAttempted = false;
|
||||
bool dictOpenOk = false;
|
||||
|
||||
Popup popup = Popup::None;
|
||||
StrId popupMsg = StrId::STR_DICT_NOT_FOUND;
|
||||
unsigned long popupTime = 0;
|
||||
|
||||
// Differential highlight repaint: the pixels under the current highlight
|
||||
// box, so a cursor move restores them and repaints only the two affected
|
||||
// boxes instead of re-running the full two-pass page render (which also
|
||||
// reloads every SD-font glyph on the page). snapshotIdx is the word whose
|
||||
// under-pixels are saved; -1 means the framebuffer no longer holds a clean
|
||||
// page (popup drawn, sub-activity shown) and the next render must be full.
|
||||
static constexpr size_t SNAPSHOT_CAPACITY = 4096;
|
||||
std::unique_ptr<uint8_t[]> snapshot;
|
||||
int16_t snapshotX = 0;
|
||||
int16_t snapshotY = 0;
|
||||
int16_t snapshotW = 0;
|
||||
int16_t snapshotH = 0;
|
||||
int snapshotIdx = -1;
|
||||
|
||||
// The activity is entered while Confirm is still held (long-press trigger):
|
||||
// ignore the stale release until a fresh press is seen.
|
||||
bool confirmPressSeen = false;
|
||||
};
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "BookmarkEntry.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderBookmarksActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
@@ -257,6 +258,29 @@ void EpubReaderActivity::openReaderMenu() {
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openDictionaryWordSelect() {
|
||||
if (SETTINGS.dictionaryName[0] == '\0') {
|
||||
showDictionaryMessage = true;
|
||||
dictionaryMessageTime = millis();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (!section) return;
|
||||
auto page = section->loadPage(section->currentPage);
|
||||
if (!page) return;
|
||||
|
||||
// Word geometry must match render(): viewable-area margins plus screen margin.
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += SETTINGS.screenMargin;
|
||||
orientedMarginLeft += SETTINGS.screenMargin;
|
||||
|
||||
startActivityForResult(std::make_unique<DictionaryWordSelectActivity>(renderer, mappedInput, std::move(page),
|
||||
orientedMarginLeft, orientedMarginTop),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loop() {
|
||||
if (!epub) {
|
||||
// Should never happen
|
||||
@@ -379,6 +403,11 @@ void EpubReaderActivity::loop() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (showDictionaryMessage && (millis() - dictionaryMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
|
||||
showDictionaryMessage = false;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
||||
@@ -442,6 +471,14 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_DICTIONARY:
|
||||
// Hold ~0.4s starts dictionary word selection on the current page.
|
||||
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showDictionaryMessage) {
|
||||
ignoreNextConfirmRelease = true; // Prevent menu open on the release that follows
|
||||
openDictionaryWordSelect();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_DISABLED:
|
||||
default:
|
||||
break;
|
||||
@@ -710,6 +747,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
});
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DICTIONARY: {
|
||||
openDictionaryWordSelect();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
|
||||
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
|
||||
std::string fullText = section->getTextFromSectionFile();
|
||||
@@ -1300,6 +1341,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
if (showBookmarkMessage) {
|
||||
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
|
||||
}
|
||||
|
||||
if (showDictionaryMessage) {
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_NO_DICT_SET));
|
||||
}
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::applyDeferredReposition() {
|
||||
|
||||
@@ -39,6 +39,9 @@ class EpubReaderActivity final : public Activity {
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
bool automaticPageTurnActive = false;
|
||||
bool showBookmarkMessage = false;
|
||||
// "No dictionary set" popup, shown when a lookup is triggered without a configured dictionary.
|
||||
bool showDictionaryMessage = false;
|
||||
unsigned long dictionaryMessageTime = 0UL;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
bool bookmarkRemoved = false; // true when last toggle removed (controls popup text)
|
||||
@@ -119,6 +122,7 @@ class EpubReaderActivity final : public Activity {
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
void openDictionaryWordSelect();
|
||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||
// because no KOReader credentials are stored.
|
||||
bool launchKOReaderSync();
|
||||
|
||||
@@ -22,7 +22,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
|
||||
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
|
||||
bool hasBookmarks) {
|
||||
std::vector<MenuItem> items;
|
||||
items.reserve(12);
|
||||
items.reserve(13);
|
||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||
if (hasFootnotes) {
|
||||
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
||||
@@ -31,6 +31,7 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
|
||||
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
|
||||
}
|
||||
items.push_back({MenuAction::TOGGLE_BOOKMARK, StrId::STR_TOGGLE_BOOKMARK});
|
||||
items.push_back({MenuAction::DICTIONARY, StrId::STR_LOOKUP});
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||
|
||||
@@ -24,7 +24,8 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
DISPLAY_QR,
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
DELETE_CACHE
|
||||
DELETE_CACHE,
|
||||
DICTIONARY
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
|
||||
@@ -39,7 +39,12 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
// reader activity ran — otherwise the font-family picker shows stale list.
|
||||
sdFontSystem.refreshIfDirty();
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
|
||||
// Rescan /dictionaries on every rebuild: cheap (one directory listing) and
|
||||
// picks up dictionaries copied to the SD card since the last visit.
|
||||
std::vector<DictionaryEntry> dictionaries;
|
||||
DictionaryRegistry::discover(dictionaries);
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry(), &dictionaries)) {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(setting);
|
||||
|
||||
Reference in New Issue
Block a user