Merge pull request #82 from jpirnay/feat-globalbookmarks
feat: Add global bookmarks screen
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
struct Bookmark {
|
||||
uint16_t spineIndex;
|
||||
uint16_t pageNumber;
|
||||
std::string name; // optional user-provided label (empty = use default)
|
||||
};
|
||||
+4
-6
@@ -8,15 +8,13 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Bookmark.h"
|
||||
|
||||
// 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)
|
||||
};
|
||||
using Bookmark = ::Bookmark;
|
||||
|
||||
// Load bookmarks from the cache directory (e.g. .crosspoint/epub_<hash>/).
|
||||
void load(const std::string& cachePath) {
|
||||
@@ -85,7 +83,7 @@ class BookmarkStore {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookmarks.size() > UINT16_MAX) {
|
||||
if (bookmarks.size() > MAX_BOOKMARKS) {
|
||||
LOG_ERR("BKM", "Too many bookmarks to save: %u", static_cast<unsigned>(bookmarks.size()));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ bool CrossPointState::loadFromBinaryFile() {
|
||||
}
|
||||
|
||||
koReaderSyncSession.clear();
|
||||
pendingBookmarkJump.clear();
|
||||
|
||||
inputFile.close();
|
||||
return true;
|
||||
|
||||
@@ -18,6 +18,20 @@ enum class KOReaderSyncOutcomeState : uint8_t {
|
||||
APPLIED_REMOTE = 5,
|
||||
};
|
||||
|
||||
struct PendingBookmarkJumpState {
|
||||
bool active = false;
|
||||
std::string bookPath; // source file path for disambiguation
|
||||
uint16_t spineIndex = 0; // EPUB spine; ignored for TXT
|
||||
uint16_t pageNumber = 0; // page within spine (EPUB) or global page (TXT)
|
||||
|
||||
void clear() {
|
||||
active = false;
|
||||
bookPath.clear();
|
||||
spineIndex = 0;
|
||||
pageNumber = 0;
|
||||
}
|
||||
};
|
||||
|
||||
struct KOReaderSyncSessionState {
|
||||
bool active = false;
|
||||
std::string epubPath;
|
||||
@@ -60,6 +74,7 @@ class CrossPointState {
|
||||
uint8_t readerActivityLoadCount = 0;
|
||||
bool lastSleepFromReader = false;
|
||||
KOReaderSyncSessionState koReaderSyncSession;
|
||||
PendingBookmarkJumpState pendingBookmarkJump;
|
||||
~CrossPointState() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <numeric>
|
||||
|
||||
GlobalBookmarkIndex GlobalBookmarkIndex::instance;
|
||||
|
||||
namespace {
|
||||
void writeString(FsFile& f, const std::string& s) {
|
||||
const uint16_t len = static_cast<uint16_t>(std::min<size_t>(s.size(), UINT16_MAX));
|
||||
f.write(reinterpret_cast<const uint8_t*>(&len), sizeof(len));
|
||||
if (len > 0) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(s.data()), len);
|
||||
}
|
||||
}
|
||||
|
||||
bool readString(FsFile& f, std::string& out) {
|
||||
uint16_t len = 0;
|
||||
if (f.read(reinterpret_cast<uint8_t*>(&len), sizeof(len)) != sizeof(len)) return false;
|
||||
out.clear();
|
||||
if (len == 0) return true;
|
||||
out.resize(len);
|
||||
return f.read(reinterpret_cast<uint8_t*>(&out[0]), len) == len;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<GlobalBookmarkIndex::Entry>::iterator GlobalBookmarkIndex::findBySourcePath(const std::string& sourcePath) {
|
||||
return std::find_if(entries.begin(), entries.end(),
|
||||
[&sourcePath](const Entry& e) { return e.sourcePath == sourcePath; });
|
||||
}
|
||||
|
||||
void GlobalBookmarkIndex::load() {
|
||||
entries.clear();
|
||||
loaded = true;
|
||||
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("GBI", FILE_PATH, f)) {
|
||||
LOG_DBG("GBI", "No existing global bookmarks file");
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t version = 0;
|
||||
if (f.read(&version, 1) != 1 || version != FILE_VERSION) {
|
||||
LOG_ERR("GBI", "Bad version: %u", version);
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t entryCount = 0;
|
||||
if (f.read(reinterpret_cast<uint8_t*>(&entryCount), sizeof(entryCount)) != sizeof(entryCount)) {
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
|
||||
entries.reserve(entryCount);
|
||||
for (uint16_t i = 0; i < entryCount; i++) {
|
||||
Entry e;
|
||||
uint8_t isTxtByte = 0;
|
||||
if (!readString(f, e.sourcePath) || !readString(f, e.cacheDir) || !readString(f, e.title) ||
|
||||
f.read(&isTxtByte, 1) != 1) {
|
||||
LOG_ERR("GBI", "Truncated entry %u", i);
|
||||
entries.clear();
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
e.isTxt = (isTxtByte != 0);
|
||||
|
||||
uint16_t bmCount = 0;
|
||||
if (f.read(reinterpret_cast<uint8_t*>(&bmCount), sizeof(bmCount)) != sizeof(bmCount)) {
|
||||
entries.clear();
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
e.bookmarks.reserve(bmCount);
|
||||
for (uint16_t j = 0; j < bmCount; j++) {
|
||||
BookmarkStore::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) ||
|
||||
!readString(f, bm.name)) {
|
||||
LOG_ERR("GBI", "Truncated bookmark");
|
||||
entries.clear();
|
||||
f.close();
|
||||
return;
|
||||
}
|
||||
e.bookmarks.push_back(std::move(bm));
|
||||
}
|
||||
entries.push_back(std::move(e));
|
||||
}
|
||||
f.close();
|
||||
LOG_DBG("GBI", "Loaded %u entries", static_cast<unsigned>(entries.size()));
|
||||
}
|
||||
|
||||
void GlobalBookmarkIndex::save() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("GBI", FILE_PATH, f)) {
|
||||
LOG_ERR("GBI", "Failed to open for write");
|
||||
return;
|
||||
}
|
||||
|
||||
const uint8_t version = FILE_VERSION;
|
||||
f.write(&version, 1);
|
||||
const uint16_t entryCount = static_cast<uint16_t>(std::min<size_t>(entries.size(), UINT16_MAX));
|
||||
f.write(reinterpret_cast<const uint8_t*>(&entryCount), sizeof(entryCount));
|
||||
|
||||
for (uint16_t i = 0; i < entryCount; i++) {
|
||||
const Entry& e = entries[i];
|
||||
writeString(f, e.sourcePath);
|
||||
writeString(f, e.cacheDir);
|
||||
writeString(f, e.title);
|
||||
const uint8_t isTxtByte = e.isTxt ? 1 : 0;
|
||||
f.write(&isTxtByte, 1);
|
||||
const uint16_t bmCount = static_cast<uint16_t>(std::min<size_t>(e.bookmarks.size(), UINT16_MAX));
|
||||
f.write(reinterpret_cast<const uint8_t*>(&bmCount), sizeof(bmCount));
|
||||
for (const auto& bm : e.bookmarks) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(&bm.spineIndex), sizeof(bm.spineIndex));
|
||||
f.write(reinterpret_cast<const uint8_t*>(&bm.pageNumber), sizeof(bm.pageNumber));
|
||||
writeString(f, bm.name);
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
LOG_DBG("GBI", "Saved %u entries", static_cast<unsigned>(entryCount));
|
||||
}
|
||||
|
||||
void GlobalBookmarkIndex::upsertFromStore(const std::string& sourcePath, const std::string& cacheDir,
|
||||
const std::string& title, bool isTxt,
|
||||
const std::vector<BookmarkStore::Bookmark>& bookmarks) {
|
||||
if (!loaded) load();
|
||||
|
||||
auto it = findBySourcePath(sourcePath);
|
||||
if (bookmarks.empty()) {
|
||||
if (it != entries.end()) {
|
||||
entries.erase(it);
|
||||
save();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (it == entries.end()) {
|
||||
Entry e;
|
||||
e.sourcePath = sourcePath;
|
||||
e.cacheDir = cacheDir;
|
||||
e.title = title;
|
||||
e.isTxt = isTxt;
|
||||
e.bookmarks = bookmarks;
|
||||
entries.push_back(std::move(e));
|
||||
} else {
|
||||
it->cacheDir = cacheDir;
|
||||
it->title = title;
|
||||
it->isTxt = isTxt;
|
||||
it->bookmarks = bookmarks;
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
void GlobalBookmarkIndex::syncFromStore(const BookmarkStore& store, const std::string& sourcePath,
|
||||
const std::string& cacheDir, const std::string& title, bool isTxt) {
|
||||
upsertFromStore(sourcePath, cacheDir, title, isTxt, store.getAll());
|
||||
}
|
||||
|
||||
void GlobalBookmarkIndex::removeBySourcePath(const std::string& sourcePath) {
|
||||
if (!loaded) load();
|
||||
auto it = findBySourcePath(sourcePath);
|
||||
if (it != entries.end()) {
|
||||
entries.erase(it);
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
bool GlobalBookmarkIndex::reconcile() {
|
||||
if (!loaded) load();
|
||||
const size_t before = entries.size();
|
||||
entries.erase(std::remove_if(entries.begin(), entries.end(),
|
||||
[](const Entry& e) {
|
||||
if (!Storage.exists(e.sourcePath.c_str())) {
|
||||
LOG_DBG("GBI", "Dropping orphan entry: missing sourcePath=%s", e.sourcePath.c_str());
|
||||
return true;
|
||||
}
|
||||
if (!Storage.exists(e.cacheDir.c_str())) {
|
||||
LOG_DBG("GBI", "Dropping orphan entry: missing cacheDir=%s", e.cacheDir.c_str());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
entries.end());
|
||||
const bool changed = entries.size() != before;
|
||||
if (changed) save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
size_t GlobalBookmarkIndex::totalBookmarkCount() const {
|
||||
return std::accumulate(entries.begin(), entries.end(), static_cast<size_t>(0),
|
||||
[](size_t sum, const Entry& e) { return sum + e.bookmarks.size(); });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "BookmarkStore.h"
|
||||
|
||||
// Aggregates bookmarks from every indexed book into a single queryable catalog.
|
||||
// Persisted as /.crosspoint/global_bookmarks.bin; updated incrementally when
|
||||
// per-book BookmarkStore instances save, and reconciled against the filesystem
|
||||
// when GlobalBookmarksActivity opens.
|
||||
class GlobalBookmarkIndex {
|
||||
public:
|
||||
struct Entry {
|
||||
std::string sourcePath; // e.g. /books/foo.epub
|
||||
std::string cacheDir; // e.g. /.crosspoint/epub_12345
|
||||
std::string title; // display title
|
||||
bool isTxt = false; // hint for jump dispatch
|
||||
std::vector<BookmarkStore::Bookmark> bookmarks;
|
||||
};
|
||||
|
||||
// Singleton access.
|
||||
static GlobalBookmarkIndex& getInstance() { return instance; }
|
||||
|
||||
// Persist/load the index to/from /.crosspoint/global_bookmarks.bin.
|
||||
void load();
|
||||
void save() const;
|
||||
|
||||
// Per-book update. Replaces (or removes when empty) the entry for this source path.
|
||||
// Safe to call during BookmarkStore::save() hook.
|
||||
void upsertFromStore(const std::string& sourcePath, const std::string& cacheDir, const std::string& title, bool isTxt,
|
||||
const std::vector<BookmarkStore::Bookmark>& bookmarks);
|
||||
|
||||
// Drop an entry (e.g. when a book is deleted). No-op if not indexed.
|
||||
void removeBySourcePath(const std::string& sourcePath);
|
||||
|
||||
// Convenience wrapper that pulls bookmarks out of a BookmarkStore and upserts.
|
||||
// Also removes the entry if the store is empty.
|
||||
void syncFromStore(const BookmarkStore& store, const std::string& sourcePath, const std::string& cacheDir,
|
||||
const std::string& title, bool isTxt);
|
||||
|
||||
// Walk every entry; stat sourcePath + cacheDir. Drop entries whose source file
|
||||
// is missing. Called on GlobalBookmarksActivity entry.
|
||||
// Returns true if anything changed (callers can decide whether to persist).
|
||||
bool reconcile();
|
||||
|
||||
[[nodiscard]] const std::vector<Entry>& getEntries() const { return entries; }
|
||||
[[nodiscard]] bool isEmpty() const { return entries.empty(); }
|
||||
|
||||
// Total bookmark count across all entries.
|
||||
[[nodiscard]] size_t totalBookmarkCount() const;
|
||||
|
||||
private:
|
||||
static GlobalBookmarkIndex instance;
|
||||
|
||||
std::vector<Entry> entries;
|
||||
bool loaded = false;
|
||||
|
||||
static constexpr uint8_t FILE_VERSION = 1;
|
||||
static constexpr const char* FILE_PATH = "/.crosspoint/global_bookmarks.bin";
|
||||
|
||||
std::vector<Entry>::iterator findBySourcePath(const std::string& sourcePath);
|
||||
};
|
||||
|
||||
#define GLOBAL_BOOKMARKS GlobalBookmarkIndex::getInstance()
|
||||
@@ -73,6 +73,7 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) {
|
||||
doc["lastSleepImage"] = s.lastSleepImage;
|
||||
doc["readerActivityLoadCount"] = s.readerActivityLoadCount;
|
||||
doc["lastSleepFromReader"] = s.lastSleepFromReader;
|
||||
// Information about a pending KOReader sync session
|
||||
JsonObject sync = doc["koReaderSyncSession"].to<JsonObject>();
|
||||
sync["active"] = s.koReaderSyncSession.active;
|
||||
sync["epubPath"] = s.koReaderSyncSession.epubPath;
|
||||
@@ -87,6 +88,12 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) {
|
||||
sync["resultPage"] = s.koReaderSyncSession.resultPage;
|
||||
sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex;
|
||||
sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex;
|
||||
// Information about a pending bookmark jump
|
||||
JsonObject jump = doc["pendingBookmarkJump"].to<JsonObject>();
|
||||
jump["active"] = s.pendingBookmarkJump.active;
|
||||
jump["bookPath"] = s.pendingBookmarkJump.bookPath;
|
||||
jump["spineIndex"] = s.pendingBookmarkJump.spineIndex;
|
||||
jump["pageNumber"] = s.pendingBookmarkJump.pageNumber;
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
@@ -121,6 +128,12 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) {
|
||||
s.koReaderSyncSession.resultPage = sync["resultPage"] | 0;
|
||||
s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0;
|
||||
s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false;
|
||||
|
||||
JsonObject jump = doc["pendingBookmarkJump"].as<JsonObject>();
|
||||
s.pendingBookmarkJump.active = jump["active"] | false;
|
||||
s.pendingBookmarkJump.bookPath = jump["bookPath"] | std::string("");
|
||||
s.pendingBookmarkJump.spineIndex = jump["spineIndex"] | (uint16_t)0;
|
||||
s.pendingBookmarkJump.pageNumber = jump["pageNumber"] | (uint16_t)0;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "boot_sleep/SleepActivity.h"
|
||||
#include "browser/OpdsBookBrowserActivity.h"
|
||||
#include "home/FileBrowserActivity.h"
|
||||
#include "home/GlobalBookmarksActivity.h"
|
||||
#include "home/HomeActivity.h"
|
||||
#include "home/RecentBooksActivity.h"
|
||||
#include "network/CrossPointWebServerActivity.h"
|
||||
@@ -221,6 +222,10 @@ void ActivityManager::goToRecentBooks() {
|
||||
replaceActivity(std::make_unique<RecentBooksActivity>(renderer, mappedInput));
|
||||
}
|
||||
|
||||
void ActivityManager::goToGlobalBookmarks() {
|
||||
replaceActivity(std::make_unique<GlobalBookmarksActivity>(renderer, mappedInput));
|
||||
}
|
||||
|
||||
void ActivityManager::goToBrowser() {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput));
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ class ActivityManager {
|
||||
void goToSettings();
|
||||
void goToFileBrowser(std::string path = {});
|
||||
void goToRecentBooks();
|
||||
void goToGlobalBookmarks();
|
||||
void goToBrowser();
|
||||
void goToReader(std::string path);
|
||||
void goToKOReaderSync();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#include "GlobalBookmarksActivity.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "BookmarkStore.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void GlobalBookmarksActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
GLOBAL_BOOKMARKS.reconcile();
|
||||
rebuildRows();
|
||||
|
||||
const int first = firstSelectableIndex();
|
||||
selectorIndex = first >= 0 ? first : 0;
|
||||
|
||||
const auto total = static_cast<int>(rows.size());
|
||||
buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); }, total);
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::onExit() {
|
||||
Activity::onExit();
|
||||
rows.clear();
|
||||
buttonNavigator.clearSelectablePredicate();
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::rebuildRows() {
|
||||
rows.clear();
|
||||
const auto& entries = GLOBAL_BOOKMARKS.getEntries();
|
||||
for (size_t bi = 0; bi < entries.size(); bi++) {
|
||||
const auto& entry = entries[bi];
|
||||
if (entry.bookmarks.empty()) continue;
|
||||
Row sep;
|
||||
sep.isSeparator = true;
|
||||
sep.bookIndex = bi;
|
||||
rows.push_back(sep);
|
||||
for (size_t mi = 0; mi < entry.bookmarks.size(); mi++) {
|
||||
Row row;
|
||||
row.isSeparator = false;
|
||||
row.bookIndex = bi;
|
||||
row.bookmarkIndex = mi;
|
||||
rows.push_back(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool GlobalBookmarksActivity::isSeparatorRow(int index) const {
|
||||
return index >= 0 && index < static_cast<int>(rows.size()) && rows[index].isSeparator;
|
||||
}
|
||||
|
||||
int GlobalBookmarksActivity::firstSelectableIndex() const {
|
||||
for (size_t i = 0; i < rows.size(); i++) {
|
||||
if (!rows[i].isSeparator) return static_cast<int>(i);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::string GlobalBookmarksActivity::getRowTitle(int index) const {
|
||||
if (index < 0 || index >= static_cast<int>(rows.size())) return {};
|
||||
const auto& row = rows[index];
|
||||
const auto& entries = GLOBAL_BOOKMARKS.getEntries();
|
||||
if (row.bookIndex >= entries.size()) return {};
|
||||
const auto& entry = entries[row.bookIndex];
|
||||
|
||||
if (row.isSeparator) {
|
||||
return UITheme::makeSeparatorTitle(entry.title.empty() ? entry.sourcePath : entry.title);
|
||||
}
|
||||
|
||||
if (row.bookmarkIndex >= entry.bookmarks.size()) return {};
|
||||
const auto& bm = entry.bookmarks[row.bookmarkIndex];
|
||||
if (!bm.name.empty()) return bm.name;
|
||||
|
||||
char buf[64];
|
||||
if (entry.isTxt) {
|
||||
snprintf(buf, sizeof(buf), "%s%d", tr(STR_PAGE_PREFIX), bm.pageNumber + 1);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%s%d, %s%d", tr(STR_SECTION_PREFIX), bm.spineIndex + 1, tr(STR_PAGE_PREFIX),
|
||||
bm.pageNumber + 1);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::openSelected() {
|
||||
if (isSeparatorRow(selectorIndex)) return;
|
||||
const auto& row = rows[selectorIndex];
|
||||
const auto& entries = GLOBAL_BOOKMARKS.getEntries();
|
||||
if (row.bookIndex >= entries.size()) return;
|
||||
const auto& entry = entries[row.bookIndex];
|
||||
if (row.bookmarkIndex >= entry.bookmarks.size()) return;
|
||||
const auto& bm = entry.bookmarks[row.bookmarkIndex];
|
||||
|
||||
if (!Storage.exists(entry.sourcePath.c_str())) {
|
||||
LOG_ERR("GBA", "Source file missing, reconciling: %s", entry.sourcePath.c_str());
|
||||
GLOBAL_BOOKMARKS.removeBySourcePath(entry.sourcePath);
|
||||
GLOBAL_BOOKMARKS.save();
|
||||
rebuildRows();
|
||||
const int first = firstSelectableIndex();
|
||||
selectorIndex = first >= 0 ? first : 0;
|
||||
buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); },
|
||||
static_cast<int>(rows.size()));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
auto& jump = APP_STATE.pendingBookmarkJump;
|
||||
jump.active = true;
|
||||
jump.bookPath = entry.sourcePath;
|
||||
jump.spineIndex = bm.spineIndex;
|
||||
jump.pageNumber = bm.pageNumber;
|
||||
APP_STATE.saveToFile();
|
||||
|
||||
LOG_DBG("GBA", "Jumping to bookmark in %s at %u/%u", entry.sourcePath.c_str(), bm.spineIndex, bm.pageNumber);
|
||||
onSelectBook(entry.sourcePath);
|
||||
}
|
||||
|
||||
template <typename Op>
|
||||
void GlobalBookmarksActivity::mutateBook(size_t bookIndex, Op&& op) {
|
||||
const auto& entries = GLOBAL_BOOKMARKS.getEntries();
|
||||
if (bookIndex >= entries.size()) return;
|
||||
const auto entry = entries[bookIndex]; // copy — index may invalidate after sync
|
||||
|
||||
BookmarkStore store;
|
||||
store.load(entry.cacheDir);
|
||||
if (!op(store)) return;
|
||||
store.save();
|
||||
|
||||
GLOBAL_BOOKMARKS.syncFromStore(store, entry.sourcePath, entry.cacheDir, entry.title, entry.isTxt);
|
||||
GLOBAL_BOOKMARKS.save();
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::deleteSelected() {
|
||||
if (isSeparatorRow(selectorIndex)) return;
|
||||
const auto& row = rows[selectorIndex];
|
||||
const size_t bookmarkIndex = row.bookmarkIndex;
|
||||
|
||||
mutateBook(row.bookIndex, [bookmarkIndex](BookmarkStore& store) {
|
||||
if (bookmarkIndex >= store.getAll().size()) return false;
|
||||
store.removeAt(bookmarkIndex);
|
||||
return true;
|
||||
});
|
||||
|
||||
rebuildRows();
|
||||
const int total = static_cast<int>(rows.size());
|
||||
buttonNavigator.setSelectablePredicate([this](int index) { return !isSeparatorRow(index); }, total);
|
||||
|
||||
if (rows.empty()) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
if (selectorIndex >= total) selectorIndex = total - 1;
|
||||
if (isSeparatorRow(selectorIndex)) {
|
||||
const int next = ButtonNavigator::nextIndex(selectorIndex, total, [this](int i) { return !isSeparatorRow(i); });
|
||||
if (next >= 0) selectorIndex = next;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::renameSelected() {
|
||||
if (isSeparatorRow(selectorIndex)) return;
|
||||
const auto& row = rows[selectorIndex];
|
||||
const auto& entries = GLOBAL_BOOKMARKS.getEntries();
|
||||
if (row.bookIndex >= entries.size()) return;
|
||||
const auto& entry = entries[row.bookIndex];
|
||||
if (row.bookmarkIndex >= entry.bookmarks.size()) return;
|
||||
|
||||
const size_t bookIndex = row.bookIndex;
|
||||
const size_t bookmarkIndex = row.bookmarkIndex;
|
||||
const std::string initial =
|
||||
entry.bookmarks[bookmarkIndex].name.empty() ? getRowTitle(selectorIndex) : entry.bookmarks[bookmarkIndex].name;
|
||||
|
||||
startActivityForResult(std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_RENAME), initial,
|
||||
BookmarkStore::MAX_NAME_LENGTH, false),
|
||||
[this, bookIndex, bookmarkIndex](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kr = std::get<KeyboardResult>(result.data);
|
||||
mutateBook(bookIndex, [bookmarkIndex, &kr](BookmarkStore& store) {
|
||||
if (bookmarkIndex >= store.getAll().size()) return false;
|
||||
store.rename(bookmarkIndex, kr.text);
|
||||
return true;
|
||||
});
|
||||
rebuildRows();
|
||||
buttonNavigator.setSelectablePredicate([this](int i) { return !isSeparatorRow(i); },
|
||||
static_cast<int>(rows.size()));
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::loop() {
|
||||
const int total = static_cast<int>(rows.size());
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
if (total == 0) return;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
openSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
renameSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
deleteSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = buttonNavigator.nextIndex(selectorIndex);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
selectorIndex = buttonNavigator.previousIndex(selectorIndex);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, total, pageItems] {
|
||||
int next = ButtonNavigator::nextPageIndex(selectorIndex, total, pageItems);
|
||||
if (isSeparatorRow(next)) {
|
||||
const int adj = ButtonNavigator::nextIndex(next, total, [this](int i) { return !isSeparatorRow(i); });
|
||||
if (adj >= 0) next = adj;
|
||||
}
|
||||
selectorIndex = next;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, total, pageItems] {
|
||||
int prev = ButtonNavigator::previousPageIndex(selectorIndex, total, pageItems);
|
||||
if (isSeparatorRow(prev)) {
|
||||
const int adj = ButtonNavigator::previousIndex(prev, total, [this](int i) { return !isSeparatorRow(i); });
|
||||
if (adj >= 0) prev = adj;
|
||||
}
|
||||
selectorIndex = prev;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void GlobalBookmarksActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, true);
|
||||
|
||||
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
tr(STR_GLOBAL_BOOKMARKS));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing;
|
||||
|
||||
if (rows.empty()) {
|
||||
renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20,
|
||||
tr(STR_NO_GLOBAL_BOOKMARKS));
|
||||
} else {
|
||||
GUI.drawList(renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight},
|
||||
static_cast<int>(rows.size()), selectorIndex, [this](int index) { return getRowTitle(index); });
|
||||
}
|
||||
|
||||
const bool hasBookmarks = !rows.empty() && !isSeparatorRow(selectorIndex);
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_HOME), hasBookmarks ? tr(STR_OPEN) : "",
|
||||
hasBookmarks ? tr(STR_RENAME) : "", hasBookmarks ? tr(STR_DELETE) : "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
struct Rect;
|
||||
|
||||
// Home-screen activity that aggregates bookmarks from every indexed book and
|
||||
// jumps directly into the chosen book/position on Confirm.
|
||||
//
|
||||
// Data source: GlobalBookmarkIndex (persisted at /.crosspoint/global_bookmarks.bin).
|
||||
// Reconciles against the filesystem on entry (drops entries whose source file
|
||||
// has disappeared).
|
||||
//
|
||||
// The display list is a flat vector of rows, where each row is either a book
|
||||
// header separator or a bookmark entry belonging to the preceding header.
|
||||
class GlobalBookmarksActivity final : public Activity {
|
||||
public:
|
||||
explicit GlobalBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("GlobalBookmarks", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct Row {
|
||||
bool isSeparator = false;
|
||||
size_t bookIndex = 0; // index into GlobalBookmarkIndex entries
|
||||
size_t bookmarkIndex = 0; // index within that entry's bookmarks (separator: ignored)
|
||||
};
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
std::vector<Row> rows;
|
||||
int selectorIndex = 0;
|
||||
|
||||
void rebuildRows();
|
||||
std::string getRowTitle(int index) const;
|
||||
bool isSeparatorRow(int index) const;
|
||||
int firstSelectableIndex() const;
|
||||
|
||||
void openSelected();
|
||||
void deleteSelected();
|
||||
void renameSelected();
|
||||
|
||||
// Apply a mutation to the underlying per-book BookmarkStore + global index.
|
||||
// `op` is invoked with the loaded store; it should mutate and return true
|
||||
// when something changed worth persisting. Title/cacheDir/isTxt are taken
|
||||
// from the current index entry.
|
||||
template <typename Op>
|
||||
void mutateBook(size_t bookIndex, Op&& op);
|
||||
};
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -106,6 +107,9 @@ int HomeActivity::getMenuItemCount() const {
|
||||
if (hasOpdsUrl) {
|
||||
count++;
|
||||
}
|
||||
if (!GLOBAL_BOOKMARKS.isEmpty()) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -256,7 +260,7 @@ void HomeActivity::loop() {
|
||||
if (firstRenderDone && !recentsLoaded && !recentsLoading) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
||||
const int menuItemCount = hasOpdsUrl ? 6 : 5;
|
||||
const int menuItemCount = getMenuItemCount();
|
||||
const HomeScreenLayout layout = computeHomeScreenLayout(metrics, contentRect.height, menuItemCount);
|
||||
loadRecentCovers(getHomeCoverRenderHeight(layout));
|
||||
return;
|
||||
@@ -278,8 +282,10 @@ void HomeActivity::loop() {
|
||||
// Calculate dynamic indices based on which options are available
|
||||
int idx = 0;
|
||||
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
const bool hasGlobalBookmarks = !GLOBAL_BOOKMARKS.isEmpty();
|
||||
const int fileBrowserIdx = idx++;
|
||||
const int recentsIdx = idx++;
|
||||
const int globalBookmarksIdx = hasGlobalBookmarks ? idx++ : -1;
|
||||
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||
const int fileTransferIdx = idx++;
|
||||
const int weatherIdx = idx++;
|
||||
@@ -291,6 +297,8 @@ void HomeActivity::loop() {
|
||||
onFileBrowserOpen();
|
||||
} else if (menuSelectedIndex == recentsIdx) {
|
||||
onRecentsOpen();
|
||||
} else if (menuSelectedIndex == globalBookmarksIdx) {
|
||||
onGlobalBookmarksOpen();
|
||||
} else if (menuSelectedIndex == opdsLibraryIdx) {
|
||||
onOpdsBrowserOpen();
|
||||
} else if (menuSelectedIndex == weatherIdx) {
|
||||
@@ -317,10 +325,17 @@ void HomeActivity::render(RenderLock&&) {
|
||||
tr(STR_WEATHER), tr(STR_SETTINGS_TITLE)};
|
||||
std::vector<UIIcon> menuIcons = {Folder, Recent, Transfer, Weather, Settings};
|
||||
|
||||
int insertAfterRecents = 2;
|
||||
if (!GLOBAL_BOOKMARKS.isEmpty()) {
|
||||
menuItems.insert(menuItems.begin() + insertAfterRecents, tr(STR_GLOBAL_BOOKMARKS));
|
||||
menuIcons.insert(menuIcons.begin() + insertAfterRecents, Book);
|
||||
insertAfterRecents++;
|
||||
}
|
||||
|
||||
if (hasOpdsUrl) {
|
||||
// Insert OPDS Browser after Recents (before File Transfer)
|
||||
menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER));
|
||||
menuIcons.insert(menuIcons.begin() + 2, Library);
|
||||
// Insert OPDS Browser after Recents (and Global Bookmarks if present)
|
||||
menuItems.insert(menuItems.begin() + insertAfterRecents, tr(STR_OPDS_BROWSER));
|
||||
menuIcons.insert(menuIcons.begin() + insertAfterRecents, Library);
|
||||
}
|
||||
|
||||
const HomeScreenLayout layout =
|
||||
@@ -356,6 +371,8 @@ void HomeActivity::onFileBrowserOpen() { activityManager.goToFileBrowser(); }
|
||||
|
||||
void HomeActivity::onRecentsOpen() { activityManager.goToRecentBooks(); }
|
||||
|
||||
void HomeActivity::onGlobalBookmarksOpen() { activityManager.goToGlobalBookmarks(); }
|
||||
|
||||
void HomeActivity::onSettingsOpen() { activityManager.goToSettings(); }
|
||||
|
||||
void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); }
|
||||
|
||||
@@ -25,6 +25,7 @@ class HomeActivity final : public Activity {
|
||||
void onSelectBook(const std::string& path);
|
||||
void onFileBrowserOpen();
|
||||
void onRecentsOpen();
|
||||
void onGlobalBookmarksOpen();
|
||||
void onSettingsOpen();
|
||||
void onFileTransferOpen();
|
||||
void onOpdsBrowserOpen();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "QrDisplayActivity.h"
|
||||
@@ -93,6 +94,7 @@ void EpubReaderActivity::onEnter() {
|
||||
|
||||
epub->setupCacheDir();
|
||||
applyPendingSyncSession();
|
||||
applyPendingBookmarkJump();
|
||||
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
|
||||
@@ -145,6 +147,9 @@ void EpubReaderActivity::onExit() {
|
||||
|
||||
// Save bookmarks before exit
|
||||
bookmarkStore.save();
|
||||
if (epub) {
|
||||
GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, epub->getPath(), epub->getCachePath(), epub->getTitle(), false);
|
||||
}
|
||||
|
||||
// Reset orientation back to portrait for the rest of the UI
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
@@ -562,6 +567,8 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
if (!bookmarkStore.isEmpty()) {
|
||||
bookmarkStore.markDirty();
|
||||
bookmarkStore.save();
|
||||
GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, epub->getPath(), epub->getCachePath(), epub->getTitle(),
|
||||
false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -691,6 +698,25 @@ void EpubReaderActivity::applyPendingSyncSession() {
|
||||
logReaderMemSnapshot("after_apply_pending_sync_session");
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyPendingBookmarkJump() {
|
||||
auto& jump = APP_STATE.pendingBookmarkJump;
|
||||
if (!jump.active || !epub || jump.bookPath != epub->getPath()) {
|
||||
return;
|
||||
}
|
||||
LOG_DBG("ERS", "Applying pending bookmark jump: spine=%u page=%u", jump.spineIndex, jump.pageNumber);
|
||||
if (writeReaderProgressCache(epub->getCachePath(), jump.spineIndex, jump.pageNumber, 0)) {
|
||||
cachedSpineIndex = jump.spineIndex;
|
||||
cachedChapterTotalPageCount = 0;
|
||||
} else {
|
||||
currentSpineIndex = jump.spineIndex;
|
||||
nextPageNumber = jump.pageNumber;
|
||||
cachedSpineIndex = jump.spineIndex;
|
||||
cachedChapterTotalPageCount = 0;
|
||||
}
|
||||
jump.clear();
|
||||
APP_STATE.saveToFile();
|
||||
}
|
||||
|
||||
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
|
||||
// No-op if the selected orientation matches current settings.
|
||||
if (SETTINGS.orientation == orientation) {
|
||||
|
||||
@@ -80,6 +80,10 @@ class EpubReaderActivity final : public Activity {
|
||||
// reader startup path reads it. Upload-complete leaves the existing local
|
||||
// progress.bin untouched and simply clears the pending session marker.
|
||||
void applyPendingSyncSession();
|
||||
// Consume a persisted bookmark-jump request (from GlobalBookmarksActivity) for
|
||||
// this book. Rewrites progress.bin to the bookmarked position before the normal
|
||||
// reader startup path reads it.
|
||||
void applyPendingBookmarkJump();
|
||||
void applyOrientation(uint8_t orientation);
|
||||
void applyTextDarkness(uint8_t textDarkness);
|
||||
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
|
||||
|
||||
@@ -8,17 +8,6 @@
|
||||
#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];
|
||||
@@ -76,7 +65,6 @@ void StarredPagesActivity::deleteSelected() {
|
||||
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));
|
||||
@@ -89,16 +77,6 @@ void StarredPagesActivity::deleteSelected() {
|
||||
|
||||
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;
|
||||
@@ -108,6 +86,15 @@ void StarredPagesActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalItems == 0) return;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto& bm = bookmarkStore.getAll()[selectorIndex];
|
||||
setResult(StarredPageResult{bm.spineIndex, bm.pageNumber});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
startRename();
|
||||
return;
|
||||
@@ -118,23 +105,24 @@ void StarredPagesActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Side buttons (Up/Down) drive list navigation; Left/Right are reserved for rename/delete.
|
||||
buttonNavigator.onRelease({MappedInputManager::Button::Down}, [this, totalItems] {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onRelease({MappedInputManager::Button::Up}, [this, totalItems] {
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onContinuous({MappedInputManager::Button::Down}, [this, totalItems, pageItems] {
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onContinuous({MappedInputManager::Button::Up}, [this, totalItems, pageItems] {
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
@@ -143,48 +131,30 @@ void StarredPagesActivity::loop() {
|
||||
void StarredPagesActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect contentRect = UITheme::getContentRect(renderer, true, true);
|
||||
|
||||
GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.headerHeight},
|
||||
tr(STR_STARRED_PAGES));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = contentRect.height - contentTop - metrics.verticalSpacing;
|
||||
|
||||
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;
|
||||
renderer.drawText(UI_10_FONT_ID, contentRect.x + metrics.contentSidePadding, contentTop + 20,
|
||||
tr(STR_NO_STARRED_PAGES));
|
||||
} else {
|
||||
GUI.drawList(renderer, Rect{contentRect.x, contentTop, contentRect.width, contentHeight}, totalItems, selectorIndex,
|
||||
[this](int index) { return getItemLabel(index); });
|
||||
}
|
||||
|
||||
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));
|
||||
const bool hasItems = totalItems > 0;
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), hasItems ? tr(STR_SELECT) : "",
|
||||
hasItems ? tr(STR_RENAME) : "", hasItems ? tr(STR_DELETE) : "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawSideButtonHints(renderer, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ class StarredPagesActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
|
||||
int getPageItems() const;
|
||||
std::string getItemLabel(int index) const;
|
||||
std::string getDefaultLabel(int index) const;
|
||||
void startRename();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "RecentBooksStore.h"
|
||||
@@ -99,6 +100,7 @@ void TxtReaderActivity::onEnter() {
|
||||
}
|
||||
|
||||
txt->setupCacheDir();
|
||||
applyPendingBookmarkJump();
|
||||
|
||||
// Load bookmarks for this file
|
||||
bookmarkStore.load(txt->getCachePath());
|
||||
@@ -119,6 +121,9 @@ void TxtReaderActivity::onExit() {
|
||||
|
||||
// Save bookmarks before exit
|
||||
bookmarkStore.save();
|
||||
if (txt) {
|
||||
GLOBAL_BOOKMARKS.syncFromStore(bookmarkStore, txt->getPath(), txt->getCachePath(), txt->getTitle(), true);
|
||||
}
|
||||
|
||||
// Reset orientation back to portrait for the rest of the UI
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
@@ -428,6 +433,34 @@ void TxtReaderActivity::saveProgress() const {
|
||||
}
|
||||
}
|
||||
|
||||
void TxtReaderActivity::applyPendingBookmarkJump() {
|
||||
auto& jump = APP_STATE.pendingBookmarkJump;
|
||||
if (!jump.active || !txt || jump.bookPath != txt->getPath()) {
|
||||
return;
|
||||
}
|
||||
LOG_DBG("TRS", "Applying pending bookmark jump: page=%u", jump.pageNumber);
|
||||
|
||||
bool persisted = false;
|
||||
FsFile f;
|
||||
if (Storage.openFileForWrite("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[6] = {0};
|
||||
data[0] = jump.pageNumber & 0xFF;
|
||||
data[1] = (jump.pageNumber >> 8) & 0xFF;
|
||||
// Offset bytes stay 0: loadProgress reads only the page, and the lazy
|
||||
// initializeReader() rebuilds the page index on first render anyway.
|
||||
if (f.write(data, 6) == 6) {
|
||||
persisted = f.close();
|
||||
} else {
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (persisted) {
|
||||
jump.clear();
|
||||
APP_STATE.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
void TxtReaderActivity::loadProgress() {
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("TRS", txt->getCachePath() + "/progress.bin", f)) {
|
||||
|
||||
@@ -46,6 +46,9 @@ class TxtReaderActivity final : public Activity {
|
||||
void savePageIndexCache() const;
|
||||
void saveProgress() const;
|
||||
void loadProgress();
|
||||
// Consume a persisted bookmark-jump request (from GlobalBookmarksActivity) for
|
||||
// this TXT file. Rewrites progress.bin before initializeReader() reads it.
|
||||
void applyPendingBookmarkJump();
|
||||
|
||||
public:
|
||||
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "GlobalBookmarkIndex.h"
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
@@ -247,6 +248,7 @@ void setup() {
|
||||
APP_STATE.loadFromFile();
|
||||
HalClock::restore();
|
||||
RECENT_BOOKS.loadFromFile();
|
||||
GLOBAL_BOOKMARKS.load();
|
||||
|
||||
// Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity
|
||||
// crashed (indicated by readerActivityLoadCount > 0)
|
||||
|
||||
Reference in New Issue
Block a user