Merge remote-tracking branch 'origin/develop' into feat-deferred-refresh

This commit is contained in:
Justin Mitchell
2026-07-20 16:36:07 -04:00
164 changed files with 7526 additions and 1661 deletions
@@ -0,0 +1,254 @@
#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;
}
// Same tap zones as the reader page turns: left third = previous page,
// the rest = next. Back is the usual left-edge swipe.
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTapped(tx, ty)) {
if (tx < renderer.getScreenWidth() / 3) {
if (currentPage > 0) {
currentPage--;
requestUpdate();
}
} else if (currentPage + 1 < totalPages) {
currentPage++;
requestUpdate();
}
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,337 @@
#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 whose box (with finger-sized slop) contains the touch
// point; -1 when the touch lands on no word. Boxes never overlap after the
// slop grows them, at worst they touch, so first hit wins.
int DictionaryWordSelectActivity::wordAt(const int x, const int y) const {
constexpr int SLOP = 4; // matches the highlight box (+2) plus finger error
for (int i = 0; i < static_cast<int>(words.size()); i++) {
const WordBox& word = words[i];
if (x >= word.x - SLOP && x < word.x + word.width + SLOP && y >= word.y - SLOP && y < word.y + lineHeight + SLOP) {
return i;
}
}
return -1;
}
// 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;
// Touch: a touch-down moves the highlight to the touched word (differential
// repaint), a tap on a word selects and looks it up in one go.
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTouchDown(tx, ty)) {
const int hit = wordAt(tx, ty);
if (hit >= 0 && hit != selected) {
selected = hit;
requestUpdate();
}
return;
}
if (mappedInput.wasScreenTapped(tx, ty)) {
const int hit = wordAt(tx, ty);
if (hit >= 0) {
selected = hit;
performLookup();
}
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,86 @@
#pragma once
#include <Epub/Page.h>
#include <I18n.h>
#include <memory>
#include <vector>
#include "activities/Activity.h"
#include "util/Dictionary.h"
// 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. On touch devices a
// touch-down moves the highlight and a tap on a word looks it up directly.
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;
int wordAt(int x, int y) 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;
};
@@ -6,6 +6,10 @@
#include "CrossPointSettings.h"
#include "ReaderUtils.h"
// ReaderUtils.h pulls in ActivityManager.h, which only forward-declares Activity while holding
// std::unique_ptr<Activity> members. Destroying that unique_ptr needs the complete type, so the
// definition must be visible here.
#include "activities/Activity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ButtonNavigator.h"
+62 -17
View File
@@ -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"
@@ -269,6 +270,29 @@ bool EpubReaderActivity::buildTickHeapGate() {
return !buildHeapPaused;
}
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
@@ -397,9 +421,11 @@ void EpubReaderActivity::loop() {
pendingReadFolderMove = false;
}
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
if (automaticPageTurnActive) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
mappedInput.wasReleased(MappedInputManager::Button::Back)) {
mappedInput.wasReleased(MappedInputManager::Button::Back) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
automaticPageTurnActive = false;
// updates chapter title space to indicate page turn disabled
requestUpdate();
@@ -428,6 +454,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
@@ -457,10 +488,10 @@ void EpubReaderActivity::loop() {
}
}
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
// Enter reader menu activity on short-press Confirm or a downward swipe from the top edge. A long-press
// that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
// following the hold does not also open the menu.
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
@@ -491,26 +522,29 @@ 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;
}
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
// Short press Back restores position when viewing a footnote (takes priority over navigation)
if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
restoreSavedPosition();
return;
}
// Short press BACK goes directly to home (or restores position if viewing footnote)
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
if (footnoteDepth > 0) {
restoreSavedPosition();
return;
}
onGoHome();
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
{this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
return;
}
@@ -540,7 +574,9 @@ void EpubReaderActivity::loop() {
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -564,7 +600,8 @@ void EpubReaderActivity::loop() {
return;
}
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
// Don't skip chapter after screenshot
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
@@ -764,6 +801,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();
@@ -1355,6 +1396,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;
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
@@ -153,6 +156,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();
@@ -14,9 +14,6 @@
namespace {
constexpr int ENTER_DELETE_MODE_MS = 700;
constexpr int DELETE_MODE_OFF = 0;
constexpr int DELETE_MODE_DISPLAY = 1;
constexpr int DELETE_MODE_CONFIRM = 2;
// Layout constants used in renderScreen
constexpr int LINE_HEIGHT = 60;
@@ -64,45 +61,7 @@ int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
}
void EpubReaderBookmarksActivity::loop() {
// Delete confirmation mode
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (confirmingDelete == DELETE_MODE_DISPLAY) {
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
requestUpdate();
return;
}
bookmarks.erase(bookmarks.begin() + selectorIndex);
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
LOG_ERR("EPB", "Failed to save bookmarks after delete");
}
// Move selector up if we deleted the last item
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
selectorIndex--;
}
if (bookmarks.empty()) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
requestUpdate();
confirmingDelete = DELETE_MODE_OFF;
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
auto openBookmark = [this] {
if (bookmarks.empty()) {
return;
}
@@ -119,8 +78,18 @@ void EpubReaderBookmarksActivity::loop() {
}
setResult(std::move(result));
finish();
};
// Delete confirmation popup
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
if (confirmingDelete) {
// Popup dismissed without a selection (Back button or tap outside): cancel delete
confirmingDelete = false;
requestUpdate();
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
@@ -128,11 +97,68 @@ void EpubReaderBookmarksActivity::loop() {
return;
}
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isPortraitInverted ? 50 : 0;
const int listY = contentY + LINE_HEIGHT;
const int listHeight = getListHeight(renderer);
int tapped = 0;
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTouchDown(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
mappedInput.wasListItemTouchedDown(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
true)) {
if (selectorIndex != tapped) {
selectorIndex = tapped;
requestUpdate();
}
return;
}
if (mappedInput.wasScreenTapped(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
mappedInput.wasListItemTapped(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
true)) {
selectorIndex = tapped;
openBookmark();
return;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up && !bookmarks.empty()) {
selectorIndex =
ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down && !bookmarks.empty()) {
selectorIndex =
ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
openBookmark();
return;
}
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
if (bookmarks.empty()) {
return;
}
confirmingDelete = DELETE_MODE_DISPLAY;
confirmingDelete = true;
const char* options[] = {tr(STR_CANCEL), tr(STR_DELETE)};
confirmPopup.show(tr(STR_CONFIRM_DELETE_BOOKMARK), options, 2, 0, [this](int idx) {
confirmingDelete = false;
if (idx == 1) {
deleteSelectedBookmark();
}
requestUpdate();
});
requestUpdate();
}
@@ -159,6 +185,27 @@ void EpubReaderBookmarksActivity::loop() {
});
}
void EpubReaderBookmarksActivity::deleteSelectedBookmark() {
bookmarks.erase(bookmarks.begin() + selectorIndex);
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
LOG_ERR("EPB", "Failed to save bookmarks after delete");
}
// Move selector up if we deleted the last item
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
selectorIndex--;
}
if (bookmarks.empty()) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
}
}
void EpubReaderBookmarksActivity::render(RenderLock&&) {
renderer.clearScreen();
@@ -188,10 +235,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
const auto getBookmarkTitle = [this](int index) {
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
return bookmarks.at(confirmingDelete ? selectorIndex : index).summary;
};
const auto getBookmarkSubtitle = [this](int index) {
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
auto bookmark = bookmarks.at(confirmingDelete ? selectorIndex : index);
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
@@ -207,12 +254,9 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
};
if (numBookmarks > 0) {
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
tr(STR_CONFIRM_DELETE_BOOKMARK));
// render list with just the selected item for the user to confirm to delete
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
if (confirmingDelete) {
// Render just the selected item near the top; the confirmation popup occupies the center
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
getBookmarkSubtitle, getBookmarkIcon);
} else {
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
@@ -223,10 +267,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
}
}
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
const auto confirmLabel =
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
if (confirmPopup.processRender(renderer, mappedInput)) return;
const auto confirmLabel = bookmarks.size() > 0 ? tr(STR_SELECT) : "";
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -5,6 +5,7 @@
#include "../../BookmarkEntry.h"
#include "../Activity.h"
#include "components/OptionPopup.h"
#include "util/ButtonNavigator.h"
class EpubReaderBookmarksActivity final : public Activity {
@@ -13,7 +14,8 @@ class EpubReaderBookmarksActivity final : public Activity {
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
std::vector<BookmarkEntry> bookmarks;
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
bool confirmingDelete = false;
OptionPopup confirmPopup;
public:
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -30,4 +32,7 @@ class EpubReaderBookmarksActivity final : public Activity {
// Calculate the height available for the bookmark list based on orientation
int getListHeight(const GfxRenderer& renderer);
// Delete the currently selected bookmark and persist the list
void deleteSelectedBookmark();
};
@@ -31,7 +31,15 @@ void EpubReaderChapterSelectionActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
const int totalItems = getTotalItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
auto selectChapter = [this] {
const auto tocItem = epub->getTocItem(selectorIndex);
if (tocItem.spineIndex == -1) {
ActivityResult result;
@@ -42,11 +50,36 @@ void EpubReaderChapterSelectionActivity::loop() {
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
finish();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
};
auto metrics = UITheme::getInstance().getMetrics();
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
switch (handleListTouch(selectorIndex, totalItems, contentTop, contentHeight, false)) {
case ListTouchResult::Activated:
selectChapter();
return;
case ListTouchResult::Consumed:
return;
case ListTouchResult::None:
break;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
selectChapter();
}
buttonNavigator.onNextRelease([this, totalItems] {
@@ -18,6 +18,13 @@ void EpubReaderFootnotesActivity::onEnter() {
void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); }
void EpubReaderFootnotesActivity::loop() {
auto selectFootnote = [this] {
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
setResult(FootnoteResult{footnotes[selectedIndex].href});
finish();
}
};
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
@@ -28,13 +35,53 @@ void EpubReaderFootnotesActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
setResult(FootnoteResult{footnotes[selectedIndex].href});
finish();
}
selectFootnote();
return;
}
if (!footnotes.empty()) {
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 = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isPortraitInverted ? 50 : 0;
constexpr int lineHeight = 36;
const int listTop = 60 + contentY;
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount, contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = scrollOffset + row;
if (touched >= 0 && touched < static_cast<int>(footnotes.size())) {
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedIndex != touched) {
selectedIndex = touched;
requestUpdate();
}
} else {
selectedIndex = touched;
selectFootnote();
}
return;
}
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedIndex = std::min(static_cast<int>(footnotes.size()) - 1, selectedIndex + visibleCount);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectedIndex = std::max(0, selectedIndex - visibleCount);
requestUpdate();
return;
}
}
buttonNavigator.onNext([this] {
if (!footnotes.empty()) {
selectedIndex = (selectedIndex + 1) % footnotes.size();
@@ -83,13 +130,14 @@ void EpubReaderFootnotesActivity::render(RenderLock&&) {
constexpr int lineHeight = 36;
const int screenWidth = renderer.getScreenWidth();
const int marginLeft = contentX + 20;
const int listTop = 60 + contentY;
const int visibleCount = std::max(1, (renderer.getScreenHeight() - contentY) / lineHeight);
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
const int y = 60 + contentY + (i - scrollOffset) * lineHeight;
const int y = listTop + (i - scrollOffset) * lineHeight;
const bool isSelected = (i == selectedIndex);
if (isSelected) {
@@ -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});
@@ -49,21 +50,45 @@ void EpubReaderMenuActivity::onEnter() {
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
void EpubReaderMenuActivity::closeCancelled() {
ActivityResult result;
result.isCancelled = true;
result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption};
setResult(std::move(result));
finish();
}
bool EpubReaderMenuActivity::handleHomeGesture() {
closeCancelled();
return true;
}
void EpubReaderMenuActivity::loop() {
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) {
// The popup acts on button press; if that input closed it, the trailing
// release must be swallowed below (Back would close the menu, Confirm
// would re-activate the selected item).
popupClosing = !optionPopup.isActive();
return;
}
if (popupClosing) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) ||
mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
return; // closing press still held
}
popupClosing = false;
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
return; // swallow the release that closed the popup
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
closeCancelled();
return;
}
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
auto activateSelected = [this] {
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) {
optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast<int>(orientationLabels.size()),
@@ -87,13 +112,48 @@ void EpubReaderMenuActivity::loop() {
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
finish();
};
auto metrics = UITheme::getInstance().getMetrics();
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int contentTop =
screen.y + metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
switch (handleListTouch(selectedIndex, static_cast<int>(menuItems.size()), contentTop, contentHeight, false)) {
case ListTouchResult::Activated:
activateSelected();
return;
case ListTouchResult::Consumed:
return;
case ListTouchResult::None:
break;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption};
setResult(std::move(result));
finish();
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
requestUpdate();
return;
}
// Handle navigation
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)) {
activateSelected();
return;
}
}
@@ -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,
@@ -35,6 +36,7 @@ class EpubReaderMenuActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool handleHomeGesture() override;
private:
struct MenuItem {
@@ -43,6 +45,7 @@ class EpubReaderMenuActivity final : public Activity {
};
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes, bool hasBookmarks);
void closeCancelled();
// Fixed menu layout
const std::vector<MenuItem> menuItems;
@@ -51,6 +54,9 @@ class EpubReaderMenuActivity final : public Activity {
ButtonNavigator buttonNavigator;
OptionPopup optionPopup;
// True while the button press that closed the popup is still held; its release
// must not fall through to the menu's own Back/Confirm handlers.
bool popupClosing = false;
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
uint8_t selectedPageTurnOption = 0;
@@ -4,6 +4,7 @@
#include <HalGPIO.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include "MappedInputManager.h"
@@ -36,6 +37,38 @@ void EpubReaderPercentSelectionActivity::adjustPercent(const int delta) {
}
void EpubReaderPercentSelectionActivity::loop() {
auto& theme = UITheme::getInstance();
auto metrics = theme.getMetrics();
Rect screen = theme.getScreenSafeArea(renderer, true, false);
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 4;
constexpr int barWidth = 360;
constexpr int barHeight = 16;
const int barX = screen.x + (screen.width - barWidth) / 2;
const int barY = contentTop + metrics.verticalSpacing * 2;
int tx = 0;
int ty = 0;
// Live drag on the slider: once a touch lands on the bar, the percent follows the
// finger until release. Runs before the Back handler because the release of a drag
// can also register as a swipe (e.g. the left-edge rightward back gesture) — the
// drag must consume it so it can't cancel the dialog or step the percent.
if (mappedInput.isScreenTouchHeld(tx, ty)) {
if (draggingBar ||
(tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 && ty < barY + barHeight + 24)) {
draggingBar = true;
const int dragged = std::clamp((tx - barX) * 100 / barWidth, 0, 100);
if (dragged != percent) {
percent = dragged;
requestUpdate();
}
return;
}
} else if (draggingBar) {
// Release frame of a drag: swallow the tap/swipe events it produced.
draggingBar = false;
return;
}
// Back cancels, confirm selects, arrows adjust the percent.
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
@@ -45,6 +78,23 @@ void EpubReaderPercentSelectionActivity::loop() {
return;
}
if (mappedInput.wasScreenTapped(tx, ty) && tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 &&
ty < barY + barHeight + 24) {
percent = std::clamp((tx - barX) * 100 / barWidth, 0, 100);
requestUpdate();
return;
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Right) {
adjustPercent(kLargeStep);
return;
}
if (swipe == MappedInputManager::SwipeDir::Left) {
adjustPercent(-kLargeStep);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
setResult(PercentResult{percent});
finish();
@@ -20,6 +20,9 @@ class EpubReaderPercentSelectionActivity final : public Activity {
// Current percent value (0-100) shown on the slider.
int percent = 0;
// True while a touch that landed on the slider bar is being dragged.
bool draggingBar = false;
ButtonNavigator buttonNavigator;
// Change the current percent by a delta and clamp within bounds.
+192 -25
View File
@@ -10,6 +10,7 @@
#include <algorithm>
#include <cassert>
#include <cmath>
#include "Epub/Section.h"
#include "EpubReaderUtils.h"
@@ -24,6 +25,19 @@
#include "fontIds.h"
namespace {
std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path)
: KOReaderDocumentId::calculate(path);
}
DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
}
const char* matchMethodName(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? "filename" : "binary";
}
void syncTimeWithNTP() {
// Stop SNTP if already running (can't reconfigure while running)
if (esp_sntp_enabled()) {
@@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
bool KOReaderSyncActivity::smartSyncEnabled() const {
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART;
}
void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; }
void KOReaderSyncActivity::completeAlreadySynced() {
{
RenderLock lock(*this);
state = SYNC_COMPLETE;
}
markAutoReturn();
requestUpdate(true);
}
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
LOG_DBG("KOSync", "WiFi connection failed, exiting");
@@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
}
void KOReaderSyncActivity::performSync() {
// Calculate document hash based on user's preferred method
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
} else {
documentHash = KOReaderDocumentId::calculate(epubPath);
}
const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
if (documentHash.empty()) {
{
RenderLock lock(*this);
@@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() {
requestUpdate(true);
return;
}
const std::string primaryHash = documentHash;
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
{
RenderLock lock(*this);
@@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() {
}
requestUpdateAndWait();
// Fetch remote progress
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
// Fetch remote progress. In smart mode, also probe the alternate document-id
// method and use the furthest remote state we can find. This avoids a stale
// local upload when another KOReader device synced the same book with a
// different document matching method.
auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(),
localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str());
if (smartSyncEnabled()) {
const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod);
const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod);
if (!altHash.empty() && altHash != documentHash) {
KOReaderProgress altProgress;
const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress);
LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(),
localProgress.percentage, altProgress.percentage, altProgress.progress.c_str());
if (altResult == KOReaderSyncClient::OK &&
(result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) {
documentHash = altHash;
remoteProgress = std::move(altProgress);
result = KOReaderSyncClient::OK;
}
}
}
if (result == KOReaderSyncClient::NOT_FOUND) {
if (smartSyncEnabled()) {
LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f",
localProgress.percentage);
performUpload();
return;
}
// No remote progress - offer to upload
{
RenderLock lock(*this);
@@ -174,8 +232,42 @@ void KOReaderSyncActivity::performSync() {
return;
}
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
// Prefer the exact spine/page from a crosspoint-sync rich position (lossless
// CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping
// for plain kosync servers or when the rich position cannot be applied.
std::optional<CrossPointPosition> richMapped;
if (remoteProgress.position.has_value()) {
richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer);
}
if (richMapped.has_value()) {
remotePosition = *richMapped;
} else {
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
}
if (smartSyncEnabled()) {
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
const float delta = localProgress.percentage - remoteProgress.percentage;
LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d",
documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta,
remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber);
if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) {
completeAlreadySynced();
return;
}
if (delta > 0) {
// Alternate hashes are only probes for newer remote state. Keep uploads
// on the user's configured matching method so its primary record heals.
documentHash = primaryHash;
performUpload();
return;
}
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
return;
}
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
{
@@ -206,17 +298,45 @@ void KOReaderSyncActivity::performUpload() {
progress.progress = localProgress.xpath;
progress.percentage = localProgress.percentage;
// Rich CrossPoint position for crosspoint-sync servers (lossless
// CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field.
{
KOReaderRichPosition pos;
const float pct = localProgress.percentage < 0.0f ? 0.0f
: localProgress.percentage > 1.0f ? 1.0f
: localProgress.percentage;
pos.pctQ = static_cast<uint32_t>(pct * 1000000.0f + 0.5f);
pos.spineIndex = static_cast<uint16_t>(currentSpineIndex);
pos.pageNumber = static_cast<uint16_t>(currentPage);
pos.totalPages = static_cast<uint16_t>(totalPagesInSpine > 0 ? totalPagesInSpine : 1);
pos.paragraphIndex = currentParagraphIndex;
pos.xpath = localProgress.xpath;
progress.position = std::move(pos);
}
// Optionally include document metadata (KOReader PR #15306)
if (KOREADER_STORE.getSendMetadata()) {
// The Epub is released before the sync network calls and is only reloaded on the
// remote-progress path (performSync). When uploading from NO_REMOTE_PROGRESS the
// Epub is still null, so reload it here and guard the title/author reads to avoid
// dereferencing a null Epub. Filename is derived from the path and is always safe.
ensureEpubLoaded();
KOReaderMetadata meta;
// Extract filename from path
const auto lastSlash = epubPath.rfind('/');
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
meta.title = epub->getTitle();
meta.authors = epub->getAuthor();
if (epub) {
meta.title = epub->getTitle();
meta.authors = epub->getAuthor();
} else {
LOG_ERR("KOSync", "Epub unavailable for metadata; sending filename only");
}
progress.metadata = std::move(meta);
}
// Release the Epub before the network call so the TLS handshake has enough free heap
// (consistent with the release-before-sync pattern in performSync); nothing below needs it.
epub.reset();
const auto result = KOReaderSyncClient::updateProgress(progress);
// Drop the radio while user reads the result; full teardown happens at silent reboot.
@@ -236,6 +356,7 @@ void KOReaderSyncActivity::performUpload() {
RenderLock lock(*this);
state = UPLOAD_COMPLETE;
}
markAutoReturn();
requestUpdate(true);
}
@@ -379,10 +500,12 @@ void KOReaderSyncActivity::render(RenderLock&&) {
return;
}
if (state == UPLOAD_COMPLETE) {
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
@@ -400,14 +523,48 @@ void KOReaderSyncActivity::render(RenderLock&&) {
}
void KOReaderSyncActivity::loop() {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
if (autoReturnAt != 0 && millis() >= autoReturnAt) {
returnToReader();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
returnToReader();
}
return;
}
if (state == SHOWING_RESULT) {
auto chooseSelected = [this] {
if (selectedOption == 0) {
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
} else if (selectedOption == 1) {
performUpload();
}
};
{
const auto& metrics = UITheme::getInstance().getMetrics();
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
const int top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
constexpr int optionHeight = 30;
int touchedOption = -1;
const auto touch = mappedInput.rowTouch(touchedOption, top + 230 - 2, optionHeight, 2);
if (touch == MappedInputManager::RowTouch::Down) {
if (selectedOption != touchedOption) {
selectedOption = touchedOption;
requestUpdate();
}
return;
}
if (touch == MappedInputManager::RowTouch::Tap) {
selectedOption = touchedOption;
chooseSelected();
return;
}
}
// Navigate options
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
@@ -420,12 +577,7 @@ void KOReaderSyncActivity::loop() {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectedOption == 0) {
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
} else if (selectedOption == 1) {
// Upload local progress
performUpload();
}
chooseSelected();
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
@@ -435,6 +587,21 @@ void KOReaderSyncActivity::loop() {
}
if (state == NO_REMOTE_PROGRESS) {
int tx = 0;
int ty = 0;
if (mappedInput.wasScreenTapped(tx, ty) && ty > renderer.getScreenHeight() / 3 &&
ty < renderer.getScreenHeight() * 2 / 3) {
if (documentHash.empty()) {
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
} else {
documentHash = KOReaderDocumentId::calculate(epubPath);
}
}
performUpload();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate hash if not done yet
if (documentHash.empty()) {
+9 -1
View File
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
private:
enum State {
@@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity {
SHOWING_RESULT,
UPLOADING,
UPLOAD_COMPLETE,
SYNC_COMPLETE,
NO_REMOTE_PROGRESS,
SYNC_FAILED,
NO_CREDENTIALS
@@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity {
// Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0;
// Timed return for successful smart-sync terminal states.
unsigned long autoReturnAt = 0;
static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200;
// Tracks whether this session activated WiFi. Set in onEnter past the credentials
// check; checked in onExit to decide whether to silent-reboot. Can't rely on
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
@@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity {
void onWifiSelectionComplete(bool success);
void performSync();
void performUpload();
bool smartSyncEnabled() const;
void markAutoReturn();
void completeAlreadySynced();
void ensureEpubLoaded();
void saveProgressAndReturn(int spineIndex, int page);
void returnToReader();
+3 -1
View File
@@ -16,8 +16,10 @@ void QrDisplayActivity::onEnter() {
void QrDisplayActivity::onExit() { Activity::onExit(); }
void QrDisplayActivity::loop() {
int x = 0;
int y = 0;
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) {
finish();
return;
}
+84
View File
@@ -2,18 +2,27 @@
#include <CrossPointSettings.h>
#include <GfxRenderer.h>
#include <HalGPIO.h>
#include <HalTiltSensor.h>
#include <Logging.h>
#include <components/bars/tap-zones.h>
#include "MappedInputManager.h"
#include "activities/ActivityManager.h"
namespace ReaderUtils {
constexpr unsigned long GO_HOME_MS = 1000;
constexpr unsigned long GO_BACK_OR_HOME_MS = GO_HOME_MS;
constexpr unsigned long SKIP_HOLD_MS = 700;
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
enum ReaderTouchAction : freeink::ui::ActionId {
READER_TOUCH_PREV = 1,
READER_TOUCH_NEXT = 3,
};
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
switch (orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
@@ -59,6 +68,48 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
return {prev, next, tiltPrev || tiltNext};
}
struct TouchPageTurn {
bool prev;
bool next;
unsigned long heldMs;
};
inline TouchPageTurn detectTouchPageTurn(GfxRenderer& renderer, const MappedInputManager& input) {
TouchPageTurn result{false, false, 0};
if (!SETTINGS.touchReaderControls || !input.hasTouch()) {
return result;
}
int x = 0;
int y = 0;
if (!input.wasScreenTapped(x, y)) {
return result;
}
const int16_t width = static_cast<int16_t>(renderer.getScreenWidth());
const int16_t height = static_cast<int16_t>(renderer.getScreenHeight());
const int16_t previousZoneWidth = width / 3;
const freeink::ui::TapZone zones[] = {
{freeink::ui::Rect{0, 0, previousZoneWidth, height}, READER_TOUCH_PREV},
{freeink::ui::Rect{previousZoneWidth, 0, static_cast<int16_t>(width - previousZoneWidth), height},
READER_TOUCH_NEXT},
};
for (const auto& zone : zones) {
if (!zone.enabled || !zone.rect.contains(static_cast<int16_t>(x), static_cast<int16_t>(y))) continue;
result.prev = zone.action == READER_TOUCH_PREV;
result.next = zone.action == READER_TOUCH_NEXT;
break;
}
result.heldMs = gpio.lastTouchHeldMs();
return result;
}
// Reader menu opens on a downward swipe from the top edge (replaces the old center tap-and-hold).
inline bool isTouchMenuGesture(const MappedInputManager& input) {
return SETTINGS.touchReaderControls && input.hasTouch() && input.wasMenuGesture();
}
// One helper, blocking or deferred: the async form starts the refresh and
// returns so the caller can overlap CPU work with the panel's refresh time.
// Async callers must not touch the framebuffer until
@@ -105,4 +156,37 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
renderer.restoreBwBuffer();
}
struct BackNavCallback {
void* ctx;
void (*fn)(void*);
};
// Returns true if the back button was consumed (caller should return).
// Long press (>= GO_BACK_OR_HOME_MS):
// - default: go to file browser
// - with backShortToFileBrowser: go home
// Short press (< GO_BACK_OR_HOME_MS):
// - default: go home
// - with backShortToFileBrowser: go to file browser.
inline bool handleBackNavigation(const MappedInputManager& mappedInput, ActivityManager& activityManager,
const char* filePath, BackNavCallback goHome) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_BACK_OR_HOME_MS) {
if (SETTINGS.backShortToFileBrowser) {
goHome.fn(goHome.ctx);
} else {
activityManager.goToFileBrowser(filePath);
}
return true;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < GO_BACK_OR_HOME_MS) {
if (SETTINGS.backShortToFileBrowser) {
activityManager.goToFileBrowser(filePath);
} else {
goHome.fn(goHome.ctx);
}
return true;
}
return false;
}
} // namespace ReaderUtils
+6 -11
View File
@@ -60,20 +60,15 @@ void TxtReaderActivity::onExit() {
}
void TxtReaderActivity::loop() {
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "",
{this, [](void* ctx) { static_cast<TxtReaderActivity*>(ctx)->onGoHome(); }})) {
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
if (!prevTriggered && !nextTriggered) {
return;
}
+11 -14
View File
@@ -69,6 +69,8 @@ void XtcReaderActivity::loop() {
return;
}
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
const bool atEndOfBook = currentPage >= xtc->getPageCount();
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
@@ -97,24 +99,18 @@ void XtcReaderActivity::loop() {
}
// Enter chapter selection activity
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
openChapterSelection();
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "",
{this, [](void* ctx) { static_cast<XtcReaderActivity*>(ctx)->onGoHome(); }})) {
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return;
}
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
prevTriggered = prevTriggered || touch.prev;
nextTriggered = nextTriggered || touch.next;
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -136,8 +132,9 @@ void XtcReaderActivity::loop() {
return;
}
const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP &&
mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
const bool skipPages =
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && heldMs > ReaderUtils::SKIP_HOLD_MS;
const int skipAmount = skipPages ? 10 : 1;
if (prevTriggered) {
@@ -56,17 +56,63 @@ void XtcReaderChapterSelectionActivity::loop() {
const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
auto selectChapter = [this] {
const auto& chapters = xtc->getChapters();
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
setResult(PageResult{chapters[selectorIndex].startPage});
finish();
}
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
};
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 = renderer.getScreenWidth() - hintGutterWidth;
const int contentY = isPortraitInverted ? 50 : 0;
const int listTop = 60 + contentY;
int row = -1;
const auto touch = mappedInput.rowTouch(row, listTop, 30, pageItems, contentX, contentX + contentWidth);
if (touch != MappedInputManager::RowTouch::None) {
const int touched = selectorIndex / pageItems * pageItems + row;
if (touched >= 0 && touched < totalItems) {
if (touch == MappedInputManager::RowTouch::Down) {
if (selectorIndex != touched) {
selectorIndex = touched;
requestUpdate();
}
} else {
selectorIndex = touched;
selectChapter();
}
return;
}
}
const auto swipe = mappedInput.wasSwipe();
if (swipe == MappedInputManager::SwipeDir::Up) {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (swipe == MappedInputManager::SwipeDir::Down) {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
selectChapter();
}
buttonNavigator.onNextRelease([this, totalItems] {