mod: overhaul reader menu with long-press actions and quick toggles

Consolidate dictionary items: remove "Lookup Word History" and
"Delete Dictionary Cache" from the menu. Long-press on "Lookup Word"
opens history; delete-dict-cache is now a sentinel entry at the bottom
of the history list.

Replace "Reading Orientation" with "Toggle Portrait/Landscape" that
toggles between two configurable preferred orientations (new settings:
Preferred Portrait, Preferred Landscape). Long-press opens a manual
4-option orientation sub-menu.

Add "Toggle Font Size" menu item that cycles through font sizes and
applies on menu exit (with section re-layout).

Rename "Letterbox Fill" to "Override Letterbox Fill" and
"Sync Progress" to "Sync Reading Progress" in reader menu.

All long-press flows use ignoreNextConfirmRelease to prevent the
button release from triggering actions on the subsequent screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-16 18:45:46 -05:00
parent 61fb11cae3
commit 1d7971ae60
11 changed files with 248 additions and 48 deletions

View File

@@ -250,8 +250,8 @@ void EpubReaderActivity::loop() {
exitActivity();
enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
SETTINGS.orientation, SETTINGS.fontSize, hasDictionary, isBookmarked, epub->getCachePath(),
[this](const uint8_t orientation, const uint8_t fontSize) { onReaderMenuBack(orientation, fontSize); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
}
@@ -342,11 +342,13 @@ void EpubReaderActivity::loop() {
}
}
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation, const uint8_t fontSize) {
exitActivity();
// Apply the user-selected orientation when the menu is dismissed.
// This ensures the menu can be navigated without immediately rotating the screen.
applyOrientation(orientation);
// Apply font size change (no-op if unchanged).
applyFontSize(fontSize);
// Force a half refresh on the next render to clear menu/popup artifacts
pagesUntilFullRefresh = 1;
requestUpdate();
@@ -563,24 +565,6 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}));
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
if (Dictionary::cacheExists()) {
Dictionary::deleteCache();
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
} else {
{
RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
break;
}
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
@@ -706,7 +690,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
exitActivity();
enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
[this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; },
true)); // initialSkipRelease: consumed the long-press that triggered this
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
@@ -766,6 +751,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
case EpubReaderMenuActivity::MenuAction::TOGGLE_FONT_SIZE:
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
break;
}
@@ -798,6 +784,28 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
}
}
void EpubReaderActivity::applyFontSize(const uint8_t fontSize) {
if (SETTINGS.fontSize == fontSize) {
return;
}
// Preserve current reading position so we can restore after reflow.
{
RenderLock lock(*this);
if (section) {
cachedSpineIndex = currentSpineIndex;
cachedChapterTotalPageCount = section->pageCount;
nextPageNumber = section->currentPage;
}
SETTINGS.fontSize = fontSize;
SETTINGS.saveToFile();
// Reset section to force re-layout with the new font size.
section.reset();
}
}
// TODO: Failure handling
void EpubReaderActivity::render(Activity::RenderLock&& lock) {
if (!epub) {

View File

@@ -33,9 +33,10 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void saveProgress(int spineIndex, int currentPage, int pageCount);
// Jump to a percentage of the book (0-100), mapping it to spine and page.
void jumpToPercent(int percent);
void onReaderMenuBack(uint8_t orientation);
void onReaderMenuBack(uint8_t orientation, uint8_t fontSize);
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
void applyOrientation(uint8_t orientation);
void applyFontSize(uint8_t fontSize);
public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,

View File

@@ -3,6 +3,7 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -20,6 +21,55 @@ void EpubReaderMenuActivity::loop() {
return;
}
// --- Orientation sub-menu mode ---
if (orientationSelectMode) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else {
pendingOrientation = static_cast<uint8_t>(orientationSelectIndex);
orientationSelectMode = false;
requestUpdate();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
orientationSelectMode = false;
ignoreNextConfirmRelease = false;
requestUpdate();
return;
}
buttonNavigator.onNext([this] {
orientationSelectIndex = ButtonNavigator::nextIndex(orientationSelectIndex,
static_cast<int>(orientationLabels.size()));
requestUpdate();
});
buttonNavigator.onPrevious([this] {
orientationSelectIndex = ButtonNavigator::previousIndex(orientationSelectIndex,
static_cast<int>(orientationLabels.size()));
requestUpdate();
});
return;
}
// --- Long-press detection (before release checks) ---
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::LOOKUP) {
ignoreNextConfirmRelease = true;
auto cb = onAction;
cb(MenuAction::LOOKED_UP_WORDS);
return;
}
if (selectedAction == MenuAction::ROTATE_SCREEN) {
orientationSelectMode = true;
ignoreNextConfirmRelease = true;
orientationSelectIndex = pendingOrientation;
requestUpdate();
return;
}
}
// Handle navigation
buttonNavigator.onNext([this] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
@@ -31,12 +81,29 @@ void EpubReaderMenuActivity::loop() {
requestUpdate();
});
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::ROTATE_SCREEN) {
// Cycle orientation preview locally; actual rotation happens on menu exit.
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
// Toggle between the two preferred orientations.
// If currently in a portrait-category orientation (Portrait/Inverted), switch to preferredLandscape.
// If currently in a landscape-category orientation (CW/CCW), switch to preferredPortrait.
const bool isCurrentlyPortrait = (pendingOrientation == CrossPointSettings::PORTRAIT ||
pendingOrientation == CrossPointSettings::INVERTED);
if (isCurrentlyPortrait) {
pendingOrientation = SETTINGS.preferredLandscape;
} else {
pendingOrientation = SETTINGS.preferredPortrait;
}
requestUpdate();
return;
}
if (selectedAction == MenuAction::TOGGLE_FONT_SIZE) {
pendingFontSize = (pendingFontSize + 1) % CrossPointSettings::FONT_SIZE_COUNT;
requestUpdate();
return;
}
@@ -58,9 +125,9 @@ void EpubReaderMenuActivity::loop() {
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Return the pending orientation to the parent so it can apply on exit.
onBack(pendingOrientation);
return; // Also return here just in case
// Return the pending orientation and font size to the parent so it can apply on exit.
onBack(pendingOrientation, pendingFontSize);
return;
}
}
@@ -120,6 +187,11 @@ void EpubReaderMenuActivity::render(Activity::RenderLock&&) {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
if (menuItems[i].action == MenuAction::TOGGLE_FONT_SIZE) {
const char* value = I18N.get(fontSizeLabels[pendingFontSize]);
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
// Render current letterbox fill value on the right edge of the content area.
const char* value = I18N.get(letterboxFillLabels[letterboxFillToIndex()]);
@@ -128,6 +200,30 @@ void EpubReaderMenuActivity::render(Activity::RenderLock&&) {
}
}
// --- Orientation sub-menu overlay ---
if (orientationSelectMode) {
constexpr int popupMargin = 15;
constexpr int popupLineHeight = 28;
const int optionCount = static_cast<int>(orientationLabels.size());
const int popupH = popupMargin * 2 + popupLineHeight * optionCount;
const int popupW = contentWidth - 60;
const int popupX = contentX + (contentWidth - popupW) / 2;
const int popupY = 180 + hintGutterHeight;
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
renderer.fillRect(popupX, popupY, popupW, popupH, false);
for (int i = 0; i < optionCount; ++i) {
const int optY = popupY + popupMargin + i * popupLineHeight;
const bool isSel = (i == orientationSelectIndex);
if (isSel) {
renderer.fillRect(popupX + 2, optY, popupW - 4, popupLineHeight, true);
}
const char* label = I18N.get(orientationLabels[i]);
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, optY, label, !isSel);
}
}
// Footer / Hints
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);

View File

@@ -7,6 +7,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "CrossPointSettings.h"
#include "util/BookSettings.h"
#include "util/ButtonNavigator.h"
@@ -19,6 +20,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
LOOKUP,
LOOKED_UP_WORDS,
ROTATE_SCREEN,
TOGGLE_FONT_SIZE,
LETTERBOX_FILL,
SELECT_CHAPTER,
GO_TO_BOOKMARK,
@@ -26,19 +28,20 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
GO_HOME,
SYNC,
DELETE_CACHE,
DELETE_DICT_CACHE
};
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const bool hasDictionary,
const bool isBookmarked, const std::string& bookCachePath,
const std::function<void(uint8_t)>& onBack,
const uint8_t currentOrientation, const uint8_t currentFontSize,
const bool hasDictionary, const bool isBookmarked,
const std::string& bookCachePath,
const std::function<void(uint8_t, uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title),
pendingOrientation(currentOrientation),
pendingFontSize(currentFontSize),
bookCachePath(bookCachePath),
currentPage(currentPage),
totalPages(totalPages),
@@ -68,8 +71,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
ButtonNavigator buttonNavigator;
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
uint8_t pendingFontSize = 0;
const std::vector<StrId> orientationLabels = {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED,
StrId::STR_LANDSCAPE_CCW};
const std::vector<StrId> fontSizeLabels = {StrId::STR_SMALL, StrId::STR_MEDIUM, StrId::STR_LARGE,
StrId::STR_X_LARGE};
std::string bookCachePath;
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
@@ -80,7 +86,15 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
int totalPages = 0;
int bookProgressPercent = 0;
const std::function<void(uint8_t)> onBack;
// Long-press state
bool ignoreNextConfirmRelease = false;
static constexpr unsigned long LONG_PRESS_MS = 700;
// Orientation sub-menu state (entered via long-press on Toggle Portrait/Landscape)
bool orientationSelectMode = false;
int orientationSelectIndex = 0;
const std::function<void(uint8_t, uint8_t)> onBack;
const std::function<void(MenuAction)> onAction;
// Map the internal override value to an index into letterboxFillLabels.
@@ -111,19 +125,16 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
}
if (hasDictionary) {
items.push_back({MenuAction::LOOKUP, StrId::STR_LOOKUP_WORD});
items.push_back({MenuAction::LOOKED_UP_WORDS, StrId::STR_LOOKUP_HISTORY});
}
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
items.push_back({MenuAction::LETTERBOX_FILL, StrId::STR_LETTERBOX_FILL});
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_TOGGLE_ORIENTATION});
items.push_back({MenuAction::TOGGLE_FONT_SIZE, StrId::STR_TOGGLE_FONT_SIZE});
items.push_back({MenuAction::LETTERBOX_FILL, StrId::STR_OVERRIDE_LETTERBOX_FILL});
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_TABLE_OF_CONTENTS});
items.push_back({MenuAction::GO_TO_BOOKMARK, StrId::STR_GO_TO_BOOKMARK});
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
items.push_back({MenuAction::GO_HOME, StrId::STR_CLOSE_BOOK});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
if (hasDictionary) {
items.push_back({MenuAction::DELETE_DICT_CACHE, StrId::STR_DELETE_DICT_CACHE});
}
return items;
}

View File

@@ -1,6 +1,7 @@
#include "LookedUpWordsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
@@ -16,6 +17,9 @@ void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter();
words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
// Append the "Delete Dictionary Cache" sentinel entry
words.push_back("\xE2\x80\x94 " + std::string(tr(STR_DELETE_DICT_CACHE)));
deleteDictCacheIndex = static_cast<int>(words.size()) - 1;
requestUpdate();
}
@@ -39,6 +43,7 @@ void LookedUpWordsActivity::loop() {
return;
}
// Empty list has only the sentinel entry; if even that's gone, just go back.
if (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -57,6 +62,10 @@ void LookedUpWordsActivity::loop() {
// Confirm delete
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
words.erase(words.begin() + pendingDeleteIndex);
// Adjust sentinel index since we removed an item before it
if (deleteDictCacheIndex > pendingDeleteIndex) {
deleteDictCacheIndex--;
}
if (selectedIndex >= static_cast<int>(words.size())) {
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
}
@@ -72,9 +81,10 @@ void LookedUpWordsActivity::loop() {
return;
}
// Detect long press on Confirm to trigger delete
// Detect long press on Confirm to trigger delete (only for real word entries, not sentinel)
constexpr unsigned long DELETE_HOLD_MS = 700;
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
if (selectedIndex != deleteDictCacheIndex &&
mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
deleteConfirmMode = true;
ignoreNextConfirmRelease = true;
pendingDeleteIndex = selectedIndex;
@@ -106,6 +116,33 @@ void LookedUpWordsActivity::loop() {
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Consume stale release from long-press navigation into this activity
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
// Handle the "Delete Dictionary Cache" sentinel entry
if (selectedIndex == deleteDictCacheIndex) {
if (Dictionary::cacheExists()) {
Dictionary::deleteCache();
{
Activity::RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
} else {
{
Activity::RenderLock lock(*this);
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
}
vTaskDelay(1500 / portTICK_PERIOD_MS);
requestUpdate();
return;
}
const std::string& headword = words[selectedIndex];
Rect popupLayout;
@@ -197,6 +234,9 @@ void LookedUpWordsActivity::render(Activity::RenderLock&&) {
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
// The list always has at least the sentinel entry
const bool hasRealWords = (deleteDictCacheIndex > 0);
if (words.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
} else {
@@ -234,8 +274,8 @@ void LookedUpWordsActivity::render(Activity::RenderLock&&) {
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} else {
// "Hold select to delete" hint above button hints
if (!words.empty()) {
// "Hold select to delete" hint above button hints (only when real words exist)
if (hasRealWords) {
const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;

View File

@@ -10,13 +10,14 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone)
const std::function<void()>& onDone, bool initialSkipRelease = false)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack),
onDone(onDone) {}
onDone(onDone),
ignoreNextConfirmRelease(initialSkipRelease) {}
void onEnter() override;
void onExit() override;
@@ -41,5 +42,9 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
bool ignoreNextConfirmRelease = false;
int pendingDeleteIndex = 0;
// Sentinel index: the "Delete Dictionary Cache" entry at the end of the list.
// -1 if not present (shouldn't happen when dictionary exists).
int deleteDictCacheIndex = -1;
int getPageItems() const;
};