Files
crosspoint-reader-mod/src/activities/reader/EpubReaderMenuActivity.cpp
cottongin 4cf395aee9 port: upstream PR #1342 - Book Info screen, richer metadata, safer controls
Ports upstream PR #1342 (feat: Add Book Info screen, richer metadata,
and safer file-browser controls) with mod-specific adaptations:

- Parse and cache series, seriesIndex, description from EPUB OPF
- Bump book.bin cache version to 6 for new metadata fields
- Add BookInfoActivity (new screen) accessible via Right button in FileBrowser
- Add ManageBook menu via Left button in FileBrowser (replaces upstream hidden delete)
- Guard all delete/archive actions with ConfirmationActivity (10 call sites)
- Add inputArmed gating to ConfirmationActivity to prevent accidental confirmation
- Safe deserialization: readString now returns bool with MAX_STRING_LENGTH guard
- Add series field to RecentBooksStore with JSON and binary serialization
- Add i18n keys: STR_BOOK_INFO, STR_AUTHOR, STR_SERIES, STR_FILE_SIZE, etc.

Made-with: Cursor
2026-03-09 00:39:32 -04:00

262 lines
11 KiB
C++

#include "EpubReaderMenuActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& title, const int currentPage, const int totalPages,
const int bookProgressPercent, const uint8_t currentOrientation,
const bool hasFootnotes, bool isBookmarked, uint8_t currentFontSize,
const std::string& bookCachePath)
: Activity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasFootnotes, isBookmarked)),
title(title),
pendingOrientation(currentOrientation),
pendingFontSize(currentFontSize < CrossPointSettings::FONT_SIZE_COUNT ? currentFontSize : 0),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
bookCachePath(bookCachePath) {
if (!bookCachePath.empty()) {
auto bookSettings = BookSettings::load(bookCachePath);
pendingLetterboxFill = bookSettings.letterboxFillOverride;
}
}
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
bool isBookmarked) {
std::vector<MenuItem> items;
items.reserve(16);
// Mod menu order
if (isBookmarked) {
items.push_back({MenuAction::REMOVE_BOOKMARK, StrId::STR_REMOVE_BOOKMARK});
} else {
items.push_back({MenuAction::ADD_BOOKMARK, StrId::STR_ADD_BOOKMARK});
}
items.push_back({MenuAction::DICTIONARY, StrId::STR_DICTIONARY});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
}
items.push_back({MenuAction::TOGGLE_ORIENTATION, 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::TABLE_OF_CONTENTS, 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::CLOSE_BOOK, StrId::STR_CLOSE_BOOK});
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::PUSH_AND_SLEEP, StrId::STR_PUSH_AND_SLEEP});
items.push_back({MenuAction::MANAGE_BOOK, StrId::STR_MANAGE_BOOK});
return items;
}
void EpubReaderMenuActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
void EpubReaderMenuActivity::loop() {
constexpr unsigned long longPressMs = 700;
constexpr int orientationCount = CrossPointSettings::ORIENTATION_COUNT;
// Long-press Confirm on orientation item opens the sub-menu
if (!orientationSubMenuOpen && mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
mappedInput.getHeldTime() >= longPressMs && menuItems[selectedIndex].action == MenuAction::TOGGLE_ORIENTATION) {
orientationSubMenuOpen = true;
ignoreNextConfirmRelease = true;
orientationSubMenuIndex = pendingOrientation;
requestUpdate();
return;
}
// When orientation sub-menu is open, intercept all input
if (orientationSubMenuOpen) {
buttonNavigator.onNext([this] {
orientationSubMenuIndex = (orientationSubMenuIndex + 1) % orientationCount;
requestUpdate();
});
buttonNavigator.onPrevious([this] {
orientationSubMenuIndex = (orientationSubMenuIndex + orientationCount - 1) % orientationCount;
requestUpdate();
});
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
pendingOrientation = static_cast<uint8_t>(orientationSubMenuIndex);
orientationSubMenuOpen = false;
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
orientationSubMenuOpen = false;
requestUpdate();
return;
}
return;
}
// Normal menu 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)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
return;
}
const auto selectedAction = menuItems[selectedIndex].action;
if (selectedAction == MenuAction::TOGGLE_ORIENTATION) {
const bool isCurrentlyPortrait =
(pendingOrientation == CrossPointSettings::PORTRAIT || pendingOrientation == CrossPointSettings::INVERTED);
pendingOrientation = isCurrentlyPortrait ? SETTINGS.preferredLandscape : SETTINGS.preferredPortrait;
requestUpdate();
return;
}
if (selectedAction == MenuAction::TOGGLE_FONT_SIZE) {
pendingFontSize = (pendingFontSize + 1) % CrossPointSettings::FONT_SIZE_COUNT;
requestUpdate();
return;
}
if (selectedAction == MenuAction::LETTERBOX_FILL) {
int idx = (letterboxFillToIndex() + 1) % LETTERBOX_FILL_OPTION_COUNT;
pendingLetterboxFill = indexToLetterboxFill(idx);
if (!bookCachePath.empty()) saveLetterboxFill();
requestUpdate();
return;
}
setResult(
MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption, pendingFontSize});
finish();
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption, pendingFontSize};
setResult(std::move(result));
finish();
return;
}
}
void EpubReaderMenuActivity::render(RenderLock&&) {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto screenHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
if (orientationSubMenuOpen) {
const char* subTitle = tr(STR_TOGGLE_ORIENTATION);
const int subTitleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, subTitle, EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, subTitleX, 15 + contentY, subTitle, true, EpdFontFamily::BOLD);
constexpr int lineHeight = 35;
const int listStartY = screenHeight / 2 - (CrossPointSettings::ORIENTATION_COUNT * lineHeight) / 2;
for (int i = 0; i < CrossPointSettings::ORIENTATION_COUNT; ++i) {
const int displayY = listStartY + (i * lineHeight);
const bool isSelected = (i == orientationSubMenuIndex);
if (isSelected) {
renderer.fillRect(contentX, displayY, contentWidth - 1, lineHeight, true);
}
const char* label = I18N.get(orientationLabels[i]);
renderer.drawText(UI_10_FONT_ID, contentX + 40, displayY + 4, label, !isSelected);
if (i == pendingOrientation) {
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY + 4, "*", !isSelected);
}
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
// Title
const std::string truncTitle =
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), contentWidth - 40, EpdFontFamily::BOLD);
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, truncTitle.c_str(), EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, truncTitle.c_str(), true, EpdFontFamily::BOLD);
// Progress summary
std::string progressLine;
if (totalPages > 0) {
progressLine = std::string(tr(STR_CHAPTER_PREFIX)) + std::to_string(currentPage) + "/" +
std::to_string(totalPages) + std::string(tr(STR_PAGES_SEPARATOR));
}
progressLine += std::string(tr(STR_BOOK_PREFIX)) + std::to_string(bookProgressPercent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, 45, progressLine.c_str());
// Menu Items
const int startY = 75 + contentY;
constexpr int lineHeight = 30;
for (size_t i = 0; i < menuItems.size(); ++i) {
const int displayY = startY + (i * lineHeight);
const bool isSelected = (static_cast<int>(i) == selectedIndex);
if (isSelected) {
renderer.fillRect(contentX, displayY, contentWidth - 1, lineHeight, true);
}
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, I18N.get(menuItems[i].labelId), !isSelected);
if (menuItems[i].action == MenuAction::TOGGLE_ORIENTATION) {
const char* value = I18N.get(orientationLabels[pendingOrientation]);
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) {
const char* value = I18N.get(letterboxFillLabels[letterboxFillToIndex()]);
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
}
// 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);
renderer.displayBuffer();
}