Merge pull request #78 from jpirnay/integrate-pr-1372

feat: Add bookmark feature (integrate + extend upstream pr 1372 by andreaturchet)
This commit is contained in:
jpirnay
2026-04-14 19:08:20 +02:00
committed by GitHub
20 changed files with 632 additions and 36 deletions
+7
View File
@@ -480,6 +480,8 @@ STR_IMAGE_DISPLAY_BW: ">> B&W"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray"
STR_WEATHER_MOON_INFO: "Moon"
STR_WEATHER_SUN_INFO: "Sun"
STR_READER_BOOKMARKS: "Bookmarks & Footnotes"
STR_READER_UTILS: "Helper"
STR_READER_TOOLS: "Tools"
STR_READER_NAVIGATION: "Navigation"
STR_READER_APPEARANCE: "Appearance"
@@ -497,3 +499,8 @@ STR_MENU_READER_SPACING: "Spacing"
STR_MENU_KOSYNC_SERVER: "Server Settings"
STR_MENU_KOSYNC_AUTH: "Login / Register"
STR_FORCE_REFRESH: "Refresh Screen"
STR_STARRED_PAGES: "Starred Pages"
STR_STAR_PAGE: "Star Page"
STR_NO_STARRED_PAGES: "No starred pages"
STR_RENAME: "Rename"
STR_PAGE_PREFIX: "p"
+7
View File
@@ -482,6 +482,13 @@ STR_SET_SLEEP_SCREEN: "Définir l'écran de veille"
STR_SLEEP_SCREEN_SET: "Écran de veille mis à jour !"
STR_IMAGE_DISPLAY_BW: ">> N/B"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Niveaux de gris"
STR_READER_BOOKMARKS: "Signets & notes de bas de page"
STR_READER_UTILS: "Utilitaire"
STR_STARRED_PAGES: "Pages marquées"
STR_STAR_PAGE: "Marquer la page"
STR_NO_STARRED_PAGES: "Aucune page marquée"
STR_RENAME: "Renommer"
STR_PAGE_PREFIX: "p"
STR_READER_TOOLS: "Outils"
STR_READER_NAVIGATION: "Navigation"
STR_READER_APPEARANCE: "Apparence"
+7
View File
@@ -357,6 +357,13 @@ STR_SET_SLEEP_SCREEN: "Standby-Bild setzen"
STR_SLEEP_SCREEN_SET: "Standby-Bild aktualisiert!"
STR_IMAGE_DISPLAY_BW: ">> S/W"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Grau"
STR_READER_BOOKMARKS: "Lesezeichen & Fußnoten"
STR_READER_UTILS: "Hilfsfunktionen"
STR_STARRED_PAGES: "Markierte Seiten"
STR_STAR_PAGE: "Seite markieren"
STR_NO_STARRED_PAGES: "Keine markierten Seiten"
STR_RENAME: "Umbenennen"
STR_PAGE_PREFIX: "S"
STR_READER_TOOLS: "Werkzeuge"
STR_READER_NAVIGATION: "Navigation"
+8
View File
@@ -480,6 +480,14 @@ STR_SET_SLEEP_SCREEN: "Imposta standby"
STR_SLEEP_SCREEN_SET: "Schermo standby aggiornato!"
STR_IMAGE_DISPLAY_BW: ">> B/N"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gradazioni di grigio"
STR_READER_BOOKMARKS: "Segnalibri e note"
STR_READER_UTILS: "Utilità"
STR_STARRED_PAGES: "Pagine contrassegnate"
STR_STAR_PAGE: "Contrassegna pagina"
STR_NO_STARRED_PAGES: "Nessuna pagina contrassegnata"
STR_RENAME: "Rinomina"
STR_PAGE_PREFIX: "p"
STR_WEATHER_MOON_INFO: "Luna"
STR_WEATHER_SUN_INFO: "Sole"
STR_READER_TOOLS: "Strumenti"
+7
View File
@@ -482,6 +482,13 @@ STR_SET_SLEEP_SCREEN: "Установить экран сна"
STR_SLEEP_SCREEN_SET: "Экран сна обновлён!"
STR_IMAGE_DISPLAY_BW: ">> Ч/Б"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Оттенки серого"
STR_READER_BOOKMARKS: "Закладки и сноски"
STR_READER_UTILS: "Утилиты"
STR_STARRED_PAGES: "Отмеченные страницы"
STR_STAR_PAGE: "Отметить страницу"
STR_NO_STARRED_PAGES: "Нет отмеченных страниц"
STR_RENAME: "Переименовать"
STR_PAGE_PREFIX: "с"
STR_READER_TOOLS: "Инструменты"
STR_READER_NAVIGATION: "Навигация"
STR_READER_APPEARANCE: "Внешний вид"
+7
View File
@@ -482,6 +482,13 @@ STR_SET_SLEEP_SCREEN: "Configurar suspensión"
STR_SLEEP_SCREEN_SET: "Pantalla de suspensión actualizada!"
STR_IMAGE_DISPLAY_BW: ">> N/B"
STR_IMAGE_DISPLAY_GRAYSCALE: ">> Escala de grises"
STR_READER_BOOKMARKS: "Marcadores y notas"
STR_READER_UTILS: "Utilidades"
STR_STARRED_PAGES: "Páginas marcadas"
STR_STAR_PAGE: "Marcar página"
STR_NO_STARRED_PAGES: "No hay páginas marcadas"
STR_RENAME: "Renombrar"
STR_PAGE_PREFIX: "p"
STR_READER_TOOLS: "Herramientas"
STR_READER_NAVIGATION: "Navegación"
STR_READER_APPEARANCE: "Apariencia"
+185
View File
@@ -0,0 +1,185 @@
#pragma once
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
// Stores starred/bookmarked pages for a single book.
// Persisted as a binary file on SD card within the book's cache directory.
class BookmarkStore {
public:
struct Bookmark {
uint16_t spineIndex;
uint16_t pageNumber;
std::string name; // optional user-provided label (empty = use default)
};
// Load bookmarks from the cache directory (e.g. .crosspoint/epub_<hash>/).
void load(const std::string& cachePath) {
basePath = cachePath;
bookmarks.clear();
dirty = false;
FsFile f;
if (!Storage.openFileForRead("BKM", getFilePath(), f)) {
return;
}
uint8_t version;
if (f.read(reinterpret_cast<uint8_t*>(&version), sizeof(version)) != sizeof(version) || version < 1 ||
version > FILE_VERSION) {
f.close();
return;
}
uint16_t count;
if (f.read(reinterpret_cast<uint8_t*>(&count), sizeof(count)) != sizeof(count) || count > MAX_BOOKMARKS) {
LOG_ERR("BKM", "Invalid bookmark count: %u", static_cast<unsigned>(count));
f.close();
return;
}
bookmarks.reserve(count);
for (uint16_t i = 0; i < count; i++) {
Bookmark bm;
if (f.read(reinterpret_cast<uint8_t*>(&bm.spineIndex), sizeof(bm.spineIndex)) != sizeof(bm.spineIndex) ||
f.read(reinterpret_cast<uint8_t*>(&bm.pageNumber), sizeof(bm.pageNumber)) != sizeof(bm.pageNumber)) {
LOG_ERR("BKM", "Truncated bookmarks file at entry %d", i);
bookmarks.clear();
f.close();
return;
}
if (version >= 2) {
uint16_t nameLen = 0;
if (f.read(reinterpret_cast<uint8_t*>(&nameLen), sizeof(nameLen)) != sizeof(nameLen) ||
nameLen > MAX_NAME_LENGTH) {
LOG_ERR("BKM", "Invalid bookmark name length at entry %d", i);
bookmarks.clear();
f.close();
return;
}
if (nameLen > 0) {
bm.name.resize(nameLen);
if (f.read(reinterpret_cast<uint8_t*>(&bm.name[0]), nameLen) != nameLen) {
LOG_ERR("BKM", "Truncated bookmark name at entry %d", i);
bookmarks.clear();
f.close();
return;
}
}
}
bookmarks.push_back(std::move(bm));
}
f.close();
LOG_DBG("BKM", "Loaded %d bookmarks", count);
}
// Save bookmarks to SD card (only if changed).
void save() {
if (!dirty || basePath.empty()) {
return;
}
if (bookmarks.size() > UINT16_MAX) {
LOG_ERR("BKM", "Too many bookmarks to save: %u", static_cast<unsigned>(bookmarks.size()));
return;
}
FsFile f;
if (!Storage.openFileForWrite("BKM", getFilePath(), f)) {
LOG_ERR("BKM", "Failed to save bookmarks");
return;
}
auto writePodChecked = [&f](const auto& value) {
return f.write(reinterpret_cast<const uint8_t*>(&value), sizeof(value)) == sizeof(value);
};
const uint16_t count = static_cast<uint16_t>(bookmarks.size());
bool ok = writePodChecked(FILE_VERSION) && writePodChecked(count);
for (const auto& bm : bookmarks) {
const uint16_t nameLen = static_cast<uint16_t>(std::min<size_t>(bm.name.size(), MAX_NAME_LENGTH));
ok = ok && writePodChecked(bm.spineIndex) && writePodChecked(bm.pageNumber) && writePodChecked(nameLen);
if (ok && nameLen > 0) {
ok = f.write(reinterpret_cast<const uint8_t*>(bm.name.data()), nameLen) == nameLen;
}
}
bool closeOk = false;
if (ok) {
closeOk = f.close();
if (!closeOk) {
LOG_ERR("BKM", "Failed to close bookmarks file");
return;
}
} else {
f.close();
LOG_ERR("BKM", "Failed while writing bookmarks");
return;
}
dirty = false;
LOG_DBG("BKM", "Saved %d bookmarks", count);
}
// Toggle bookmark for the given page. Returns true if now starred, false if removed.
bool toggle(uint16_t spineIndex, uint16_t pageNumber) {
auto it = find(spineIndex, pageNumber);
if (it != bookmarks.end()) {
bookmarks.erase(it);
dirty = true;
return false;
}
bookmarks.push_back({spineIndex, pageNumber});
dirty = true;
return true;
}
// Check if a page is starred.
[[nodiscard]] bool has(uint16_t spineIndex, uint16_t pageNumber) const {
return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, pageNumber](const Bookmark& bm) {
return bm.spineIndex == spineIndex && bm.pageNumber == pageNumber;
});
}
[[nodiscard]] const std::vector<Bookmark>& getAll() const { return bookmarks; }
[[nodiscard]] bool isEmpty() const { return bookmarks.empty(); }
void markDirty() { dirty = true; }
// Set or clear the name for the bookmark at index. Empty name reverts to default label.
void rename(size_t index, std::string name) {
if (index >= bookmarks.size()) return;
if (name.size() > MAX_NAME_LENGTH) name.resize(MAX_NAME_LENGTH);
bookmarks[index].name = std::move(name);
dirty = true;
}
void removeAt(size_t index) {
if (index >= bookmarks.size()) return;
bookmarks.erase(bookmarks.begin() + index);
dirty = true;
}
static constexpr uint16_t MAX_NAME_LENGTH = 128;
private:
static constexpr uint8_t FILE_VERSION = 2;
static constexpr uint16_t MAX_BOOKMARKS = 1000;
std::vector<Bookmark> bookmarks;
std::string basePath;
bool dirty = false;
[[nodiscard]] std::string getFilePath() const { return basePath + "/bookmarks.bin"; }
std::vector<Bookmark>::iterator find(uint16_t spineIndex, uint16_t pageNumber) {
return std::find_if(bookmarks.begin(), bookmarks.end(), [spineIndex, pageNumber](const Bookmark& bm) {
return bm.spineIndex == spineIndex && bm.pageNumber == pageNumber;
});
}
};
+9 -1
View File
@@ -128,7 +128,15 @@ class CrossPointSettings {
};
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, FORCE_REFRESH = 3, FOOTNOTES = 4, SHORT_PWRBTN_COUNT };
enum SHORT_PWRBTN {
IGNORE = 0,
SLEEP = 1,
PAGE_TURN = 2,
FORCE_REFRESH = 3,
FOOTNOTES = 4,
STAR_PAGE = 5,
SHORT_PWRBTN_COUNT
};
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
+4 -4
View File
@@ -124,10 +124,10 @@ inline const std::vector<SettingInfo> list = {
{StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV}, "sideButtonLayout", StrId::STR_CAT_CONTROLS),
SettingInfo::Toggle(StrId::STR_LONG_PRESS_SKIP, &CrossPointSettings::longPressChapterSkip, "longPressChapterSkip",
StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(
StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH, StrId::STR_FOOTNOTES},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
SettingInfo::Enum(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::shortPwrBtn,
{StrId::STR_IGNORE, StrId::STR_SLEEP, StrId::STR_PAGE_TURN, StrId::STR_FORCE_REFRESH,
StrId::STR_FOOTNOTES, StrId::STR_STAR_PAGE},
"shortPwrBtn", StrId::STR_CAT_CONTROLS),
// --- System ---
SettingInfo::Toggle(StrId::STR_SHOW_HIDDEN_FILES, &CrossPointSettings::showHiddenFiles, "showHiddenFiles",
+6 -1
View File
@@ -57,8 +57,13 @@ struct FootnoteResult {
std::string href;
};
struct StarredPageResult {
int spineIndex = 0;
int pageNumber = 0;
};
using ResultVariant = std::variant<std::monostate, WifiResult, KeyboardResult, MenuResult, ChapterResult, PercentResult,
PageResult, SyncResult, NetworkModeResult, FootnoteResult>;
PageResult, SyncResult, NetworkModeResult, FootnoteResult, StarredPageResult>;
struct ActivityResult {
bool isCancelled = false;
+65 -16
View File
@@ -23,6 +23,7 @@
#include "QrDisplayActivity.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "StarredPagesActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/ScreenshotUtil.h"
@@ -118,6 +119,9 @@ void EpubReaderActivity::onEnter() {
}
}
// Load bookmarks for this book
bookmarkStore.load(epub->getCachePath());
// Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile();
@@ -139,6 +143,9 @@ void EpubReaderActivity::onExit() {
Activity::onExit();
logReaderMemSnapshot("onExit_before_release");
// Save bookmarks before exit
bookmarkStore.save();
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -209,21 +216,24 @@ void EpubReaderActivity::loop() {
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
}
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, !currentPageFootnotes.empty(), bookEmbeddedStyleOverride,
bookImageRenderingOverride, SETTINGS.textDarkness),
[this](const ActivityResult& result) {
// Always apply orientation/darkness change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
applyTextDarkness(menu.textDarkness);
toggleAutoPageTurn(menu.pageTurnOption);
applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
static_cast<uint16_t>(section->currentPage));
startActivityForResult(
std::make_unique<EpubReaderMenuActivity>(
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, SETTINGS.orientation,
!currentPageFootnotes.empty(), bookEmbeddedStyleOverride, bookImageRenderingOverride, SETTINGS.textDarkness,
!bookmarkStore.isEmpty(), isCurrentPageStarred),
[this](const ActivityResult& result) {
// Always apply orientation/darkness change even if the menu was cancelled
const auto& menu = std::get<MenuResult>(result.data);
applyOrientation(menu.orientation);
applyTextDarkness(menu.textDarkness);
toggleAutoPageTurn(menu.pageTurnOption);
applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride);
if (!result.isCancelled) {
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
}
});
}
// Long press BACK (1s+) goes to home screen
@@ -265,6 +275,16 @@ void EpubReaderActivity::loop() {
return;
}
// Star page toggle via short power button press
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::STAR_PAGE &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
bookmarkStore.toggle(static_cast<uint16_t>(currentSpineIndex), static_cast<uint16_t>(section->currentPage));
requestUpdate();
}
return;
}
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
@@ -499,6 +519,29 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
requestUpdate();
break;
}
case EpubReaderMenuActivity::MenuAction::STAR_PAGE: {
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
bookmarkStore.toggle(static_cast<uint16_t>(currentSpineIndex), static_cast<uint16_t>(section->currentPage));
requestUpdate();
}
break;
}
case EpubReaderMenuActivity::MenuAction::STARRED_PAGES: {
startActivityForResult(
std::make_unique<StarredPagesActivity>(renderer, mappedInput, bookmarkStore, epub),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& starred = std::get<StarredPageResult>(result.data);
if (currentSpineIndex != starred.spineIndex || !section || section->currentPage != starred.pageNumber) {
RenderLock lock(*this);
currentSpineIndex = starred.spineIndex;
nextPageNumber = starred.pageNumber;
section.reset();
}
}
});
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
onGoHome();
return;
@@ -514,6 +557,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
epub->clearCache();
epub->setupCacheDir();
saveProgress(backupSpine, backupPage, backupPageCount);
if (!bookmarkStore.isEmpty()) {
bookmarkStore.markDirty();
bookmarkStore.save();
}
}
}
onGoHome();
@@ -1144,7 +1191,9 @@ void EpubReaderActivity::renderStatusBar() const {
title = epub->getTitle();
}
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset);
const bool isStarred = section && bookmarkStore.has(static_cast<uint16_t>(currentSpineIndex),
static_cast<uint16_t>(section->currentPage));
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, isStarred);
}
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
@@ -5,6 +5,7 @@
#include <optional>
#include "BookmarkStore.h"
#include "EpubReaderMenuActivity.h"
#include "ReaderUtils.h"
#include "activities/Activity.h"
@@ -52,6 +53,9 @@ class EpubReaderActivity final : public Activity {
int8_t bookEmbeddedStyleOverride = -1;
int8_t bookImageRenderingOverride = -1;
// Bookmarks (starred pages)
BookmarkStore bookmarkStore;
// Footnote support
std::vector<FootnoteEntry> currentPageFootnotes;
struct SavedPosition {
@@ -13,8 +13,10 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
const int bookProgressPercent, const uint8_t currentOrientation,
const bool hasFootnotes, const int8_t initialEmbeddedStyleOverride,
const int8_t initialImageRenderingOverride,
const uint8_t initialTextDarkness)
const uint8_t initialTextDarkness, const bool hasStarredPages,
const bool isCurrentPageStarred)
: MenuListActivity("EpubReaderMenu", renderer, mappedInput),
currentPageStarred(isCurrentPageStarred),
pendingOrientation(currentOrientation),
pendingEmbeddedStyleOverride(initialEmbeddedStyleOverride),
pendingImageRenderingOverride(initialImageRenderingOverride),
@@ -23,21 +25,27 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent) {
buildMenuItems(hasFootnotes);
buildMenuItems(hasFootnotes, hasStarredPages);
}
void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
menuItems.reserve(18);
void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes, bool hasStarredPages) {
menuItems.reserve(19);
// --- Navigation ---
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_NAVIGATION));
menuItems.push_back(SettingInfo::Action(StrId::STR_SELECT_CHAPTER, SettingAction::None));
menuItems.push_back(SettingInfo::Action(StrId::STR_GO_TO_PERCENT, SettingAction::None));
// Bookmarks, footnotes
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_BOOKMARKS));
menuItems.push_back(SettingInfo::Action(StrId::STR_STAR_PAGE, SettingAction::None));
if (hasStarredPages) {
menuItems.push_back(SettingInfo::Action(StrId::STR_STARRED_PAGES, SettingAction::None));
}
if (hasFootnotes) {
menuItems.push_back(SettingInfo::Action(StrId::STR_FOOTNOTES, SettingAction::None));
}
// Auto page turn: ACTION type with custom cycling in onActionSelected
menuItems.push_back(SettingInfo::Action(StrId::STR_AUTO_TURN_PAGES_PER_MIN, SettingAction::None));
// --- Appearance ---
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_APPEARANCE));
@@ -71,6 +79,10 @@ void EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
StrId::STR_TEXT_DARKNESS, {StrId::STR_NORMAL, StrId::STR_DARK, StrId::STR_EXTRA_DARK, StrId::STR_MAX_DARK},
[this]() -> uint8_t { return pendingTextDarkness; }, [this](uint8_t v) { pendingTextDarkness = v; }));
// Helper functions, reading ruler, auto page turn, orientation
menuItems.push_back(SettingInfo::Separator(StrId::STR_READER_UTILS));
// Auto page turn: ACTION type with custom cycling in onActionSelected
menuItems.push_back(SettingInfo::Action(StrId::STR_AUTO_TURN_PAGES_PER_MIN, SettingAction::None));
// Orientation: straightforward 0-3 cycle
menuItems.push_back(SettingInfo::DynamicEnum(
StrId::STR_ORIENTATION,
@@ -98,6 +110,10 @@ EpubReaderMenuActivity::MenuAction EpubReaderMenuActivity::actionForNameId(StrId
return MenuAction::SELECT_CHAPTER;
case StrId::STR_GO_TO_PERCENT:
return MenuAction::GO_TO_PERCENT;
case StrId::STR_STARRED_PAGES:
return MenuAction::STARRED_PAGES;
case StrId::STR_STAR_PAGE:
return MenuAction::STAR_PAGE;
case StrId::STR_FOOTNOTES:
return MenuAction::FOOTNOTES;
case StrId::STR_AUTO_TURN_PAGES_PER_MIN:
@@ -173,6 +189,11 @@ std::string EpubReaderMenuActivity::getItemValueString(int index) const {
return std::string(pageTurnLabels[selectedPageTurnOption]);
}
// Star page: reflect current page's star state
if (item.nameId == StrId::STR_STAR_PAGE) {
return currentPageStarred ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
}
// Plain ACTION items (select chapter, screenshot, etc.) show no value
if (item.type == SettingType::ACTION) return {};
@@ -26,6 +26,8 @@ class EpubReaderMenuActivity final : public MenuListActivity {
GO_HOME,
PULL_REMOTE,
PUSH_LOCAL,
STARRED_PAGES,
STAR_PAGE,
DELETE_CACHE
};
@@ -33,13 +35,16 @@ class EpubReaderMenuActivity final : public MenuListActivity {
const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const bool hasFootnotes,
const int8_t initialEmbeddedStyleOverride, const int8_t initialImageRenderingOverride,
const uint8_t initialTextDarkness);
const uint8_t initialTextDarkness, const bool hasStarredPages,
const bool isCurrentPageStarred);
void onEnter() override;
void render(RenderLock&&) override;
private:
void buildMenuItems(bool hasFootnotes);
void buildMenuItems(bool hasFootnotes, bool hasStarredPages);
bool currentPageStarred = false;
void finishWithAction(MenuAction action);
// MenuListActivity overrides
@@ -0,0 +1,190 @@
#include "StarredPagesActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include "MappedInputManager.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
int StarredPagesActivity::getPageItems() const {
constexpr int lineHeight = 30;
const int screenHeight = renderer.getScreenHeight();
const auto orientation = renderer.getOrientation();
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int startY = 60 + hintGutterHeight;
const int availableHeight = screenHeight - startY - lineHeight;
return std::max(1, availableHeight / lineHeight);
}
std::string StarredPagesActivity::getDefaultLabel(int index) const {
const auto& bm = bookmarkStore.getAll()[index];
char buf[64];
if (epub) {
const int tocIndex = epub->getTocIndexForSpineIndex(bm.spineIndex);
if (tocIndex != -1) {
const auto tocItem = epub->getTocItem(tocIndex);
return tocItem.title + " - " + tr(STR_PAGE_PREFIX) + std::to_string(bm.pageNumber + 1);
}
snprintf(buf, sizeof(buf), "%s%d, %s%d", tr(STR_SECTION_PREFIX), bm.spineIndex + 1, tr(STR_PAGE_PREFIX),
bm.pageNumber + 1);
} else {
snprintf(buf, sizeof(buf), "%s%d", tr(STR_PAGE_PREFIX), bm.pageNumber + 1);
}
return std::string(buf);
}
std::string StarredPagesActivity::getItemLabel(int index) const {
char prefix[16];
snprintf(prefix, sizeof(prefix), "%d. ", index + 1);
const auto& bm = bookmarkStore.getAll()[index];
return std::string(prefix) + (bm.name.empty() ? getDefaultLabel(index) : bm.name);
}
void StarredPagesActivity::onEnter() {
Activity::onEnter();
requestUpdate();
}
void StarredPagesActivity::onExit() {
bookmarkStore.save();
Activity::onExit();
}
void StarredPagesActivity::startRename() {
const auto& all = bookmarkStore.getAll();
if (all.empty() || selectorIndex >= static_cast<int>(all.size())) return;
const int renamingIndex = selectorIndex;
const std::string initial =
all[renamingIndex].name.empty() ? getDefaultLabel(renamingIndex) : all[renamingIndex].name;
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_RENAME), initial,
BookmarkStore::MAX_NAME_LENGTH, false),
[this, renamingIndex](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& kr = std::get<KeyboardResult>(result.data);
bookmarkStore.rename(renamingIndex, kr.text);
}
requestUpdate();
});
}
void StarredPagesActivity::deleteSelected() {
const auto& all = bookmarkStore.getAll();
if (all.empty() || selectorIndex >= static_cast<int>(all.size())) return;
bookmarkStore.removeAt(selectorIndex);
const int remaining = static_cast<int>(bookmarkStore.getAll().size());
if (remaining == 0) {
// Nothing left — drop back to the reader.
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
if (selectorIndex >= remaining) selectorIndex = remaining - 1;
requestUpdate();
}
void StarredPagesActivity::loop() {
const int totalItems = static_cast<int>(bookmarkStore.getAll().size());
const int pageItems = getPageItems();
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (totalItems > 0) {
const auto& bm = bookmarkStore.getAll()[selectorIndex];
setResult(StarredPageResult{bm.spineIndex, bm.pageNumber});
finish();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
startRename();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
deleteSelected();
return;
}
// Side buttons (Up/Down) drive list navigation; Left/Right are reserved for rename/delete.
buttonNavigator.onRelease({MappedInputManager::Button::Down}, [this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onRelease({MappedInputManager::Button::Up}, [this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onContinuous({MappedInputManager::Button::Down}, [this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
buttonNavigator.onContinuous({MappedInputManager::Button::Up}, [this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
}
void StarredPagesActivity::render(RenderLock&&) {
renderer.clearScreen();
const int totalItems = static_cast<int>(bookmarkStore.getAll().size());
if (totalItems == 0) {
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_NO_STARRED_PAGES), true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return;
}
const auto pageWidth = renderer.getScreenWidth();
const auto orientation = renderer.getOrientation();
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
const int contentWidth = pageWidth - hintGutterWidth;
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
const int contentY = hintGutterHeight;
const int pageItems = getPageItems();
// Title
const int titleX =
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, tr(STR_STARRED_PAGES), EpdFontFamily::BOLD)) / 2;
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_STARRED_PAGES), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Highlight selection
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
for (int i = 0; i < pageItems; i++) {
int itemIndex = pageStartIndex + i;
if (itemIndex >= totalItems) break;
const int displayY = 60 + contentY + i * 30;
const bool isSelected = (itemIndex == selectorIndex);
const std::string label = renderer.truncatedText(UI_10_FONT_ID, getItemLabel(itemIndex).c_str(), contentWidth - 40);
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
}
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_RENAME), tr(STR_DELETE));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
@@ -0,0 +1,32 @@
#pragma once
#include <Epub.h>
#include <memory>
#include <vector>
#include "../Activity.h"
#include "BookmarkStore.h"
#include "util/ButtonNavigator.h"
class StarredPagesActivity final : public Activity {
std::shared_ptr<Epub> epub; // nullptr for TXT files
BookmarkStore& bookmarkStore;
ButtonNavigator buttonNavigator;
int selectorIndex = 0;
int getPageItems() const;
std::string getItemLabel(int index) const;
std::string getDefaultLabel(int index) const;
void startRename();
void deleteSelected();
public:
explicit StarredPagesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, BookmarkStore& bookmarkStore,
std::shared_ptr<Epub> epub = nullptr)
: Activity("StarredPages", renderer, mappedInput), epub(std::move(epub)), bookmarkStore(bookmarkStore) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
};
+34 -1
View File
@@ -12,6 +12,7 @@
#include "MappedInputManager.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "StarredPagesActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -99,6 +100,9 @@ void TxtReaderActivity::onEnter() {
txt->setupCacheDir();
// Load bookmarks for this file
bookmarkStore.load(txt->getCachePath());
// Save current txt as last opened file and add to recent books
auto filePath = txt->getPath();
auto fileName = filePath.substr(filePath.rfind('/') + 1);
@@ -113,6 +117,9 @@ void TxtReaderActivity::onEnter() {
void TxtReaderActivity::onExit() {
Activity::onExit();
// Save bookmarks before exit
bookmarkStore.save();
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -143,6 +150,31 @@ void TxtReaderActivity::loop() {
return;
}
// Star page toggle via short power button press
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::STAR_PAGE &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
if (currentPage >= 0) {
bookmarkStore.toggle(0, static_cast<uint16_t>(currentPage));
}
requestUpdate();
return;
}
// Open starred pages list via Confirm button
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !bookmarkStore.isEmpty()) {
startActivityForResult(std::make_unique<StarredPagesActivity>(renderer, mappedInput, bookmarkStore),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& starred = std::get<StarredPageResult>(result.data);
currentPage = starred.pageNumber;
if (currentPage >= totalPages) currentPage = totalPages - 1;
if (currentPage < 0) currentPage = 0;
}
requestUpdate();
});
return;
}
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
@@ -373,7 +405,8 @@ void TxtReaderActivity::renderStatusBar() const {
if (SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE) {
title = txt->getTitle();
}
GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title);
const bool isStarred = bookmarkStore.has(0, static_cast<uint16_t>(currentPage));
GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, isStarred);
}
void TxtReaderActivity::saveProgress() const {
@@ -4,6 +4,7 @@
#include <vector>
#include "BookmarkStore.h"
#include "CrossPointSettings.h"
#include "ReaderUtils.h"
#include "activities/Activity.h"
@@ -16,6 +17,9 @@ class TxtReaderActivity final : public Activity {
int pagesUntilFullRefresh = 0;
ReaderUtils::InputDrainGuard inputDrainGuard;
// Bookmarks (starred pages)
BookmarkStore bookmarkStore;
// Streaming text reader - stores file offsets for each page
std::vector<size_t> pageOffsets; // File offset for start of each page
std::vector<std::string> currentPageLines;
+20 -3
View File
@@ -748,8 +748,8 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
}
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom,
const int textYOffset) const {
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
const bool isStarred) const {
auto metrics = UITheme::getInstance().getMetrics();
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
@@ -826,9 +826,10 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight;
const int batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0;
const int starReserve = isStarred ? (renderer.getTextWidth(SMALL_FONT_ID, "*") + 6) : 0;
const int clockSize = clockTextWidth > 0 ? clockTextWidth + 8 : 0;
const int titleMarginLeft = batterySize + clockSize + 30;
const int titleMarginRight = progressTextWidth + 30;
const int titleMarginRight = progressTextWidth + starReserve + 30;
// Attempt to center title on the screen, but if title is too wide then later we will center it within the
// available space.
@@ -852,6 +853,22 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
(availableTitleSpace - titleWidth) / 2,
textY, title.c_str());
}
// Draw star indicator between title and progress text
if (isStarred) {
const int starWidth = renderer.getTextWidth(SMALL_FONT_ID, "*");
int starX;
if (progressTextWidth > 0) {
// Place star just left of the progress text with a small gap
starX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth -
starWidth - 6;
} else {
// No progress text, place star at right edge
starX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - starWidth;
}
const int starY = title.empty() ? textY : (textY + textYOffset);
renderer.drawText(SMALL_FONT_ID, starX, starY, "*");
}
}
void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const {
+1 -1
View File
@@ -145,7 +145,7 @@ class BaseTheme {
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
virtual void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom = 0,
const int textYOffset = 0) const;
const int textYOffset = 0, const bool isStarred = false) const;
virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const;
virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,