diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index a9632ca9..35809988 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" @@ -496,4 +498,9 @@ STR_MENU_READER_TWEAKS: "Reader Tweaks" STR_MENU_READER_SPACING: "Spacing" STR_MENU_KOSYNC_SERVER: "Server Settings" STR_MENU_KOSYNC_AUTH: "Login / Register" -STR_FORCE_REFRESH: "Refresh Screen" \ No newline at end of file +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" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index bceccbf4..640e940f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -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" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 24be6a92..39e60ec0 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -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" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 198e8b91..3c1fd1e7 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -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" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 056f7c0b..155301bc 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -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: "Внешний вид" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index cd1344b4..45e0ead0 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -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" diff --git a/src/BookmarkStore.h b/src/BookmarkStore.h new file mode 100644 index 00000000..e1966b23 --- /dev/null +++ b/src/BookmarkStore.h @@ -0,0 +1,185 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +// 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_/). + 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(&version), sizeof(version)) != sizeof(version) || version < 1 || + version > FILE_VERSION) { + f.close(); + return; + } + + uint16_t count; + if (f.read(reinterpret_cast(&count), sizeof(count)) != sizeof(count) || count > MAX_BOOKMARKS) { + LOG_ERR("BKM", "Invalid bookmark count: %u", static_cast(count)); + f.close(); + return; + } + + bookmarks.reserve(count); + for (uint16_t i = 0; i < count; i++) { + Bookmark bm; + if (f.read(reinterpret_cast(&bm.spineIndex), sizeof(bm.spineIndex)) != sizeof(bm.spineIndex) || + f.read(reinterpret_cast(&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(&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(&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(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(&value), sizeof(value)) == sizeof(value); + }; + + const uint16_t count = static_cast(bookmarks.size()); + bool ok = writePodChecked(FILE_VERSION) && writePodChecked(count); + + for (const auto& bm : bookmarks) { + const uint16_t nameLen = static_cast(std::min(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(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& 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 bookmarks; + std::string basePath; + bool dirty = false; + + [[nodiscard]] std::string getFilePath() const { return basePath + "/bookmarks.bin"; } + + std::vector::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; + }); + } +}; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1ccd089b..8a788d98 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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 }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 8678321a..b7643590 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -124,10 +124,10 @@ inline const std::vector 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", diff --git a/src/activities/ActivityResult.h b/src/activities/ActivityResult.h index eecf572b..c7c716fe 100644 --- a/src/activities/ActivityResult.h +++ b/src/activities/ActivityResult.h @@ -57,8 +57,13 @@ struct FootnoteResult { std::string href; }; +struct StarredPageResult { + int spineIndex = 0; + int pageNumber = 0; +}; + using ResultVariant = std::variant; + PageResult, SyncResult, NetworkModeResult, FootnoteResult, StarredPageResult>; struct ActivityResult { bool isCancelled = false; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 88c06619..8068d8cb 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -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(bookProgress + 0.5f)); - startActivityForResult(std::make_unique( - 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(result.data); - applyOrientation(menu.orientation); - applyTextDarkness(menu.textDarkness); - toggleAutoPageTurn(menu.pageTurnOption); - applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride); - if (!result.isCancelled) { - onReaderMenuConfirm(static_cast(menu.action)); - } - }); + const bool isCurrentPageStarred = section && bookmarkStore.has(static_cast(currentSpineIndex), + static_cast(section->currentPage)); + startActivityForResult( + std::make_unique( + 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(result.data); + applyOrientation(menu.orientation); + applyTextDarkness(menu.textDarkness); + toggleAutoPageTurn(menu.pageTurnOption); + applyBookReaderOverrides(menu.embeddedStyleOverride, menu.imageRenderingOverride); + if (!result.isCancelled) { + onReaderMenuConfirm(static_cast(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(currentSpineIndex), static_cast(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(currentSpineIndex), static_cast(section->currentPage)); + requestUpdate(); + } + break; + } + case EpubReaderMenuActivity::MenuAction::STARRED_PAGES: { + startActivityForResult( + std::make_unique(renderer, mappedInput, bookmarkStore, epub), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& starred = std::get(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(currentSpineIndex), + static_cast(section->currentPage)); + GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, isStarred); } void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8e23dd18..b07aa667 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,6 +5,7 @@ #include +#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 currentPageFootnotes; struct SavedPosition { diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 8e1c54cf..d6a3610a 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -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 {}; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index ea1d5dc7..d5fc72c9 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -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 diff --git a/src/activities/reader/StarredPagesActivity.cpp b/src/activities/reader/StarredPagesActivity.cpp new file mode 100644 index 00000000..6ce8bfaa --- /dev/null +++ b/src/activities/reader/StarredPagesActivity.cpp @@ -0,0 +1,190 @@ +#include "StarredPagesActivity.h" + +#include +#include + +#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(all.size())) return; + const int renamingIndex = selectorIndex; + const std::string initial = + all[renamingIndex].name.empty() ? getDefaultLabel(renamingIndex) : all[renamingIndex].name; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_RENAME), initial, + BookmarkStore::MAX_NAME_LENGTH, false), + [this, renamingIndex](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kr = std::get(result.data); + bookmarkStore.rename(renamingIndex, kr.text); + } + requestUpdate(); + }); +} + +void StarredPagesActivity::deleteSelected() { + const auto& all = bookmarkStore.getAll(); + if (all.empty() || selectorIndex >= static_cast(all.size())) return; + bookmarkStore.removeAt(selectorIndex); + const int remaining = static_cast(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(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(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(); +} diff --git a/src/activities/reader/StarredPagesActivity.h b/src/activities/reader/StarredPagesActivity.h new file mode 100644 index 00000000..62aaf6b3 --- /dev/null +++ b/src/activities/reader/StarredPagesActivity.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +#include "../Activity.h" +#include "BookmarkStore.h" +#include "util/ButtonNavigator.h" + +class StarredPagesActivity final : public Activity { + std::shared_ptr 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 = nullptr) + : Activity("StarredPages", renderer, mappedInput), epub(std::move(epub)), bookmarkStore(bookmarkStore) {} + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; +}; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0df0fe1e..35b95330 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -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(currentPage)); + } + requestUpdate(); + return; + } + + // Open starred pages list via Confirm button + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !bookmarkStore.isEmpty()) { + startActivityForResult(std::make_unique(renderer, mappedInput, bookmarkStore), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& starred = std::get(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(currentPage)); + GUI.drawStatusBar(renderer, progress, currentPage + 1, totalPages, title, 0, 0, isStarred); } void TxtReaderActivity::saveProgress() const { diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index bef54ce4..f9cb16f8 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -4,6 +4,7 @@ #include +#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 pageOffsets; // File offset for start of each page std::vector currentPageLines; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 1170fc46..4edfb5b5 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -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 { diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 68b5fab0..920912a7 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -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,