Compare commits

...

10 Commits

Author SHA1 Message Date
cottongin
e8d332e34f
ci: add Gitea Actions workflows
Some checks failed
CI / build (push) Failing after 51s
Adapt GitHub Actions workflows for self-hosted Gitea instance:
- CI workflow with PlatformIO build, cppcheck, and clang-format
- PR title format checker using conventional commits
- Release workflow for tagged builds

Keeps original .github/workflows/ for upstream compatibility.
2026-01-28 05:12:49 -05:00
cottongin
54004d5a5b
chore: cleanup empty unused file 2026-01-28 03:16:06 -05:00
cottongin
d6e17c09ca
typo 2026-01-28 03:14:19 -05:00
cottongin
7288e6499d
chore: Stop tracking personal notes file
Add CrossPoint-ef.md to .gitignore to keep fork cleaner
and closer to upstream.
2026-01-28 03:13:19 -05:00
cottongin
5dab3ad5a3
feat: Library improvements - bookmarks, search, and tab navigation
Adds bookmark functionality with persistent storage, quick menu for
in-reader actions, Search tab with character picker, and unified
tab bar navigation across all library tabs.

Includes:
- BookmarkStore and BookmarkListActivity for bookmark management
- QuickMenuActivity for in-reader quick actions
- Reader bookmark integration with visual indicators
- Enhanced tab bar with scrolling, overflow indicators, and cursor
- Search tab with character picker and result navigation
- Consistent tab bar navigation (Up from top enters tab bar mode)
2026-01-28 02:51:51 -05:00
cottongin
82165c1022
feat: Wire up bookmark and quick menu features in main app
Integrates BookmarkStore initialization, QuickMenuActivity, and
BookmarkListActivity into the main application flow.
2026-01-28 02:20:58 -05:00
cottongin
e1fcec7d69
feat: Search tab with character picker and unified tab bar navigation
Adds Search tab to MyLibraryActivity with character picker for building
search queries, result navigation with long press jump-to-end support,
and Bookmarks tab integration. Implements consistent tab bar navigation
across all tabs - pressing Up from top of any list enters tab bar mode
with visible cursor indicators, Left/Right switches tabs, Down enters
list at top, and Up jumps to bottom of list.
2026-01-28 02:20:48 -05:00
cottongin
69a26ccb0e
feat: Enhanced tab bar with scrolling, overflow indicators, and cursor
Tab bar now scrolls to keep selected tab visible when content overflows.
Adds triangle overflow indicators and optional bullet cursor indicators
around the active tab for visual focus feedback.
2026-01-28 02:20:38 -05:00
cottongin
245d5a7dd8
feat: Integrate bookmark support into reader activities
Adds bookmark add/remove functionality to EpubReaderActivity and base
ReaderActivity, with visual indicator for bookmarked pages.
2026-01-28 02:20:29 -05:00
cottongin
e991fb10a6
feat: Add QuickMenuActivity for in-reader quick actions
Provides a popup menu accessible during reading for quick access
to bookmarks, settings, and other common actions.
2026-01-28 02:20:19 -05:00
20 changed files with 1787 additions and 78 deletions

37
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,37 @@
name: CI
'on':
push:
branches: [master, crosspoint-ef]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install PlatformIO Core
run: pip install --upgrade platformio
- name: Install clang-format-21
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x llvm.sh
sudo ./llvm.sh 21
sudo apt-get update
sudo apt-get install -y clang-format-21
- name: Run cppcheck
run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high
- name: Run clang-format
run: PATH="/usr/lib/llvm-21/bin:$PATH" ./bin/clang-format-fix && git diff --exit-code || (echo "Please run 'bin/clang-format-fix' to fix formatting issues" && exit 1)
- name: Build CrossPoint
run: pio run

View File

@ -0,0 +1,40 @@
name: "PR Formatting"
on:
pull_request:
types:
- opened
- reopened
- edited
- synchronize
jobs:
title-check:
name: Title Check
runs-on: ubuntu-latest
steps:
- name: Check PR Title Format
run: |
PR_TITLE="${{ github.event.pull_request.title }}"
echo "Checking PR title: $PR_TITLE"
# Conventional commit pattern: type(scope): description or type: description
# Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_-]+\))?: .+"
if echo "$PR_TITLE" | grep -qE "$PATTERN"; then
echo "✓ PR title follows conventional commit format"
else
echo "✗ PR title does not follow conventional commit format"
echo ""
echo "Expected format: type(scope): description"
echo " or: type: description"
echo ""
echo "Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo "Examples:"
echo " feat(reader): add bookmark sync feature"
echo " fix: resolve memory leak in epub parser"
echo " docs: update README with new instructions"
exit 1
fi

View File

@ -0,0 +1,41 @@
name: Compile Release
on:
push:
tags:
- '*'
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.platformio/.cache
key: ${{ runner.os }}-pio
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install PlatformIO Core
run: pip install --upgrade platformio
- name: Build CrossPoint
run: pio run -e gh_release
- name: Upload Artifacts
uses: actions/upload-artifact@v3
with:
name: CrossPoint-${{ github.ref_name }}
path: |
.pio/build/gh_release/bootloader.bin
.pio/build/gh_release/firmware.bin
.pio/build/gh_release/firmware.elf
.pio/build/gh_release/firmware.map
.pio/build/gh_release/partitions.bin

6
.gitignore vendored
View File

@ -10,4 +10,8 @@ lib/EpdFont/fontsrc
build
**/__pycache__/
test/epubs/
TODO.md
CrossPoint-ef.md
# Gitea Actions runner config (contains credentials)
.runner
.runner.*

View File

@ -1,11 +0,0 @@
## Feature Requests:
1) search for books/library
2) Bookmarks
3) quick menu
4) crosspoint logo on firmware flashing screen
5) ability to add/remove books from lists on device.
6) hide "system folders" from files view
- dictionaries/
7) sorting options for files view
8) Time spent reading tracking

0
ef.md
View File

View File

@ -86,7 +86,7 @@ class CrossPointSettings {
};
// Short power button press actions
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, SHORT_PWRBTN_COUNT };
enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2, DICTIONARY = 3, QUICK_MENU = 4, SHORT_PWRBTN_COUNT };
// Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };

View File

@ -90,33 +90,155 @@ void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const si
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs, int selectedIndex, bool showCursor) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int rightMargin = 20; // Right margin
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
constexpr int cursorPadding = 4; // Space between bullet cursor and tab text
constexpr int overflowIndicatorWidth = 16; // Space reserved for < > indicators
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int tabBarHeight = lineHeight + underlineGap + underlineHeight;
const int screenWidth = renderer.getScreenWidth();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const int availableWidth = screenWidth - bezelLeft - bezelRight - leftMargin - rightMargin;
int currentX = leftMargin;
// Find selected index if not provided
if (selectedIndex < 0) {
for (size_t i = 0; i < tabs.size(); i++) {
if (tabs[i].selected) {
selectedIndex = static_cast<int>(i);
break;
}
}
}
// Calculate total width of all tabs and individual tab widths
std::vector<int> tabWidths;
int totalWidth = 0;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
tabWidths.push_back(textWidth);
totalWidth += textWidth;
}
totalWidth += static_cast<int>(tabs.size() - 1) * tabPadding; // Add padding between tabs
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Calculate scroll offset to keep selected tab visible
int scrollOffset = 0;
if (totalWidth > availableWidth && selectedIndex >= 0) {
// Calculate position of selected tab
int selectedStart = 0;
for (int i = 0; i < selectedIndex; i++) {
selectedStart += tabWidths[i] + tabPadding;
}
int selectedEnd = selectedStart + tabWidths[selectedIndex];
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
// If selected tab would be cut off on the right, scroll left
if (selectedEnd > availableWidth) {
scrollOffset = selectedEnd - availableWidth + tabPadding;
}
// If selected tab would be cut off on the left (after scrolling), adjust
if (selectedStart - scrollOffset < 0) {
scrollOffset = selectedStart;
}
}
int currentX = leftMargin + bezelLeft - scrollOffset;
// Bullet cursor settings
constexpr int bulletRadius = 3;
const int bulletCenterY = y + lineHeight / 2;
// Calculate visible area boundaries (leave room for overflow indicators)
const bool hasLeftOverflow = scrollOffset > 0;
const bool hasRightOverflow = totalWidth > availableWidth && scrollOffset < totalWidth - availableWidth;
const int visibleLeft = bezelLeft + (hasLeftOverflow ? overflowIndicatorWidth : 0);
const int visibleRight = screenWidth - bezelRight - (hasRightOverflow ? overflowIndicatorWidth : 0);
for (size_t i = 0; i < tabs.size(); i++) {
const auto& tab = tabs[i];
const int textWidth = tabWidths[i];
// Only draw if at least partially visible (accounting for overflow indicator space)
if (currentX + textWidth > visibleLeft && currentX < visibleRight) {
// Draw bullet cursor before selected tab when showCursor is true
if (showCursor && tab.selected) {
// Draw filled circle using distance-squared check
const int bulletCenterX = currentX - cursorPadding - bulletRadius;
const int radiusSq = bulletRadius * bulletRadius;
for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) {
for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) {
if (dx * dx + dy * dy <= radiusSq) {
renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true);
}
}
}
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw bullet cursor after selected tab when showCursor is true
if (showCursor && tab.selected) {
// Draw filled circle using distance-squared check
const int bulletCenterX = currentX + textWidth + cursorPadding + bulletRadius;
const int radiusSq = bulletRadius * bulletRadius;
for (int dy = -bulletRadius; dy <= bulletRadius; ++dy) {
for (int dx = -bulletRadius; dx <= bulletRadius; ++dx) {
if (dx * dx + dy * dy <= radiusSq) {
renderer.drawPixel(bulletCenterX + dx, bulletCenterY + dy, true);
}
}
}
}
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
}
}
currentX += textWidth + tabPadding;
}
// Draw overflow indicators if content extends beyond visible area
if (totalWidth > availableWidth) {
constexpr int triangleHeight = 12; // Height of the triangle (vertical)
constexpr int triangleWidth = 6; // Width of the triangle (horizontal) - thin/elongated
const int triangleCenterY = y + lineHeight / 2;
// Left overflow indicator (more content to the left) - thin triangle pointing left
if (scrollOffset > 0) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(bezelLeft, y - 2, overflowIndicatorWidth, lineHeight + 4, false);
// Draw left-pointing triangle: point on left, base on right
const int tipX = bezelLeft + 2;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (0 at tip, full height at base)
const int lineHalfHeight = (triangleHeight * i) / (triangleWidth * 2);
renderer.drawLine(tipX + i, triangleCenterY - lineHalfHeight,
tipX + i, triangleCenterY + lineHalfHeight);
}
}
// Right overflow indicator (more content to the right) - thin triangle pointing right
if (scrollOffset < totalWidth - availableWidth) {
// Clear background behind indicator to hide any overlapping text
renderer.fillRect(screenWidth - bezelRight - overflowIndicatorWidth, y - 2, overflowIndicatorWidth, lineHeight + 4, false);
// Draw right-pointing triangle: base on left, point on right
const int baseX = screenWidth - bezelRight - 2 - triangleWidth;
for (int i = 0; i < triangleWidth; ++i) {
// Scale height based on position (full height at base, 0 at tip)
const int lineHalfHeight = (triangleHeight * (triangleWidth - 1 - i)) / (triangleWidth * 2);
renderer.drawLine(baseX + i, triangleCenterY - lineHalfHeight,
baseX + i, triangleCenterY + lineHalfHeight);
}
}
}
return tabBarHeight;
}

View File

@ -23,7 +23,9 @@ class ScreenComponents {
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
// When selectedIndex is provided, tabs scroll so the selected tab is visible
// When showCursor is true, bullet indicators are drawn around the selected tab
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs, int selectedIndex = -1, bool showCursor = false);
// Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3")

File diff suppressed because it is too large Load Diff

View File

@ -19,9 +19,25 @@ struct ThumbExistsCache {
bool exists = false; // Whether thumbnail exists
};
// Search result for the Search tab
struct SearchResult {
std::string path;
std::string title;
std::string author;
int matchScore = 0; // Higher = better match
};
// Book with bookmarks info for the Bookmarks tab
struct BookmarkedBook {
std::string path;
std::string title;
std::string author;
int bookmarkCount = 0;
};
class MyLibraryActivity final : public Activity {
public:
enum class Tab { Recent, Lists, Files };
enum class Tab { Recent, Lists, Bookmarks, Search, Files };
enum class UIState { Normal, ActionMenu, Confirming, ListActionMenu, ListConfirmingDelete, ClearAllRecentsConfirming };
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
@ -32,6 +48,7 @@ class MyLibraryActivity final : public Activity {
Tab currentTab = Tab::Recent;
int selectorIndex = 0;
bool updateRequired = false;
bool inTabBar = false; // true = focus on tab bar for switching tabs (all tabs)
// Action menu state
UIState uiState = UIState::Normal;
@ -61,6 +78,17 @@ class MyLibraryActivity final : public Activity {
int listMenuSelection = 0; // 0 = Pin/Unpin, 1 = Delete
std::string listActionTargetName;
// Bookmarks tab state
std::vector<BookmarkedBook> bookmarkedBooks;
// Search tab state
std::string searchQuery;
std::vector<SearchResult> searchResults;
std::vector<SearchResult> allBooks; // Cached index of all books
std::vector<char> searchCharacters; // Dynamic character set from library
int searchCharIndex = 0; // Current position in character picker
bool searchInResults = false; // true = navigating results, false = in character picker
// Files tab state (from FileSelectionActivity)
std::string basepath = "/";
std::vector<std::string> files;
@ -69,6 +97,7 @@ class MyLibraryActivity final : public Activity {
const std::function<void()> onGoHome;
const std::function<void(const std::string& path, Tab fromTab)> onSelectBook;
const std::function<void(const std::string& listName)> onSelectList;
const std::function<void(const std::string& path, const std::string& title)> onSelectBookmarkedBook;
// Number of items that fit on a page
int getPageItems() const;
@ -79,6 +108,9 @@ class MyLibraryActivity final : public Activity {
// Data loading
void loadRecentBooks();
void loadLists();
void loadBookmarkedBooks();
void loadAllBooks();
void updateSearchResults();
void loadFiles();
size_t findEntry(const std::string& name) const;
@ -88,10 +120,16 @@ class MyLibraryActivity final : public Activity {
void render() const;
void renderRecentTab() const;
void renderListsTab() const;
void renderBookmarksTab() const;
void renderSearchTab() const;
void renderFilesTab() const;
void renderActionMenu() const;
void renderConfirmation() const;
// Search character picker helpers
void buildSearchCharacters();
void renderCharacterPicker(int y) const;
// Action handling
void openActionMenu();
void executeAction();
@ -114,13 +152,15 @@ class MyLibraryActivity final : public Activity {
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook,
const std::function<void(const std::string& listName)>& onSelectList,
const std::function<void(const std::string& path, const std::string& title)>& onSelectBookmarkedBook = nullptr,
Tab initialTab = Tab::Recent, std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput),
currentTab(initialTab),
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onGoHome(onGoHome),
onSelectBook(onSelectBook),
onSelectList(onSelectList) {}
onSelectList(onSelectList),
onSelectBookmarkedBook(onSelectBookmarkedBook) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -8,6 +8,7 @@
#include <Serialization.h>
#include "BookManager.h"
#include "BookmarkStore.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "EpubReaderChapterSelectionActivity.h"
@ -17,6 +18,7 @@
#include "activities/dictionary/DictionaryMenuActivity.h"
#include "activities/dictionary/DictionarySearchActivity.h"
#include "activities/dictionary/EpubWordSelectionActivity.h"
#include "activities/util/QuickMenuActivity.h"
#include "fontIds.h"
namespace {
@ -366,6 +368,149 @@ void EpubReaderActivity::loop() {
return;
}
// Quick Menu power button press
if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::QUICK_MENU &&
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// Check if current page is bookmarked
bool isBookmarked = false;
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
isBookmarked = BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset);
}
exitActivity();
enterNewActivity(new QuickMenuActivity(
renderer, mappedInput,
[this](QuickMenuAction action) {
// Cache values before exitActivity
EpubReaderActivity* self = this;
GfxRenderer& cachedRenderer = renderer;
MappedInputManager& cachedMappedInput = mappedInput;
Section* cachedSection = section.get();
SemaphoreHandle_t cachedMutex = renderingMutex;
exitActivity();
if (action == QuickMenuAction::DICTIONARY) {
// Open dictionary menu
self->enterNewActivity(new DictionaryMenuActivity(
cachedRenderer, cachedMappedInput,
[self](DictionaryMode mode) {
GfxRenderer& r = self->renderer;
MappedInputManager& m = self->mappedInput;
Section* s = self->section.get();
SemaphoreHandle_t mtx = self->renderingMutex;
self->exitActivity();
if (mode == DictionaryMode::ENTER_WORD) {
self->enterNewActivity(new DictionarySearchActivity(r, m,
[self]() {
self->exitActivity();
self->updateRequired = true;
}, ""));
} else if (s) {
xSemaphoreTake(mtx, portMAX_DELAY);
auto page = s->loadPageFromSectionFile();
if (page) {
int mt, mr, mb, ml;
r.getOrientedViewableTRBL(&mt, &mr, &mb, &ml);
mt += SETTINGS.screenMargin;
ml += SETTINGS.screenMargin;
const int fontId = SETTINGS.getReaderFontId();
self->enterNewActivity(new EpubWordSelectionActivity(
r, m, std::move(page), fontId, ml, mt,
[self](const std::string& word) {
self->exitActivity();
self->enterNewActivity(new DictionarySearchActivity(
self->renderer, self->mappedInput,
[self]() {
self->exitActivity();
self->updateRequired = true;
}, word));
},
[self]() {
self->exitActivity();
self->updateRequired = true;
}));
xSemaphoreGive(mtx);
} else {
xSemaphoreGive(mtx);
self->updateRequired = true;
}
} else {
self->updateRequired = true;
}
},
[self]() {
self->exitActivity();
self->updateRequired = true;
},
self->section != nullptr));
} else if (action == QuickMenuAction::ADD_BOOKMARK) {
// Toggle bookmark on current page
if (self->section) {
const uint32_t contentOffset = self->section->getContentOffsetForPage(self->section->currentPage);
const std::string& bookPath = self->epub->getPath();
if (BookmarkStore::isPageBookmarked(bookPath, self->currentSpineIndex, contentOffset)) {
// Remove bookmark
BookmarkStore::removeBookmark(bookPath, self->currentSpineIndex, contentOffset);
} else {
// Add bookmark with auto-generated name
Bookmark bm;
bm.spineIndex = self->currentSpineIndex;
bm.contentOffset = contentOffset;
bm.pageNumber = self->section->currentPage;
bm.timestamp = millis() / 1000; // Approximate timestamp
// Generate name: "Chapter - Page X" or fallback
std::string chapterTitle;
const int tocIndex = self->epub->getTocIndexForSpineIndex(self->currentSpineIndex);
if (tocIndex >= 0) {
chapterTitle = self->epub->getTocItem(tocIndex).title;
}
if (!chapterTitle.empty()) {
bm.name = chapterTitle + " - Page " + std::to_string(self->section->currentPage + 1);
} else {
bm.name = "Page " + std::to_string(self->section->currentPage + 1);
}
BookmarkStore::addBookmark(bookPath, bm);
}
}
self->updateRequired = true;
} else if (action == QuickMenuAction::CLEAR_CACHE) {
// Navigate to Clear Cache activity
if (self->onGoToClearCache) {
xSemaphoreGive(cachedMutex);
self->onGoToClearCache();
return;
}
self->updateRequired = true;
} else if (action == QuickMenuAction::GO_TO_SETTINGS) {
// Navigate to Settings activity
if (self->onGoToSettings) {
xSemaphoreGive(cachedMutex);
self->onGoToSettings();
return;
}
self->updateRequired = true;
}
},
[this]() {
EpubReaderActivity* self = this;
exitActivity();
self->updateRequired = true;
},
isBookmarked));
xSemaphoreGive(renderingMutex);
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
@ -632,6 +777,24 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
// Draw bookmark indicator (folded corner) if this page is bookmarked
if (section) {
const uint32_t contentOffset = section->getContentOffsetForPage(section->currentPage);
if (BookmarkStore::isPageBookmarked(epub->getPath(), currentSpineIndex, contentOffset)) {
// Draw folded corner in top-right
const int screenWidth = renderer.getScreenWidth();
constexpr int cornerSize = 20;
const int cornerX = screenWidth - orientedMarginRight - cornerSize;
const int cornerY = orientedMarginTop;
// Draw triangle (folded corner effect)
const int xPoints[3] = {cornerX, cornerX + cornerSize, cornerX + cornerSize};
const int yPoints[3] = {cornerY, cornerY, cornerY + cornerSize};
renderer.fillPolygon(xPoints, yPoints, 3, true); // Black triangle
}
}
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);

View File

@ -18,6 +18,8 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
// End-of-book prompt state
bool showingEndOfBookPrompt = false;
@ -38,11 +40,15 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("EpubReader", renderer, mappedInput),
epub(std::move(epub)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
onGoHome(onGoHome),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -62,7 +62,11 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
currentBookPath = epubPath;
exitActivity();
enterNewActivity(new EpubReaderActivity(
renderer, mappedInput, std::move(epub), [this, epubPath] { goToLibrary(epubPath); }, [this] { onGoBack(); }));
renderer, mappedInput, std::move(epub),
[this, epubPath] { goToLibrary(epubPath); },
[this] { onGoBack(); },
onGoToClearCache,
onGoToSettings));
}
void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {

View File

@ -13,6 +13,8 @@ class ReaderActivity final : public ActivityWithSubactivity {
MyLibraryActivity::Tab libraryTab; // Track which tab to return to
const std::function<void()> onGoBack;
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::unique_ptr<Txt> loadTxt(const std::string& path);
static bool isTxtFile(const std::string& path);
@ -25,11 +27,15 @@ class ReaderActivity final : public ActivityWithSubactivity {
public:
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
MyLibraryActivity::Tab libraryTab, const std::function<void()>& onGoBack,
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary)
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("Reader", renderer, mappedInput),
initialBookPath(std::move(initialBookPath)),
libraryTab(libraryTab),
onGoBack(onGoBack),
onGoToLibrary(onGoToLibrary) {}
onGoToLibrary(onGoToLibrary),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
};

View File

@ -20,6 +20,8 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
bool updateRequired = false;
const std::function<void()> onGoBack;
const std::function<void()> onGoHome;
const std::function<void()> onGoToClearCache;
const std::function<void()> onGoToSettings;
// End-of-book prompt state
bool showingEndOfBookPrompt = false;
@ -56,11 +58,15 @@ class TxtReaderActivity final : public ActivityWithSubactivity {
public:
explicit TxtReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Txt> txt,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome,
const std::function<void()>& onGoToClearCache = nullptr,
const std::function<void()>& onGoToSettings = nullptr)
: ActivityWithSubactivity("TxtReader", renderer, mappedInput),
txt(std::move(txt)),
onGoBack(onGoBack),
onGoHome(onGoHome) {}
onGoHome(onGoHome),
onGoToClearCache(onGoToClearCache),
onGoToSettings(onGoToSettings) {}
void onEnter() override;
void onExit() override;
void loop() override;

View File

@ -84,7 +84,7 @@ const SettingInfo controlsSettings[controlsSettingsCount] = {
{"Prev, Next", "Next, Prev"}),
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn,
{"Ignore", "Sleep", "Page Turn", "Dictionary"})};
{"Ignore", "Sleep", "Page Turn", "Dictionary", "Quick Menu"})};
constexpr int systemSettingsCount = 4;
const SettingInfo systemSettings[systemSettingsCount] = {

View File

@ -0,0 +1,173 @@
#include "QuickMenuActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "fontIds.h"
namespace {
constexpr int MENU_ITEM_COUNT = 4;
const char* MENU_ITEMS[MENU_ITEM_COUNT] = {"Dictionary", "Bookmark", "Clear Cache", "Settings"};
const char* MENU_DESCRIPTIONS_ADD[MENU_ITEM_COUNT] = {
"Look up a word",
"Add bookmark to this page",
"Free up storage space",
"Open settings menu"
};
const char* MENU_DESCRIPTIONS_REMOVE[MENU_ITEM_COUNT] = {
"Look up a word",
"Remove bookmark from this page",
"Free up storage space",
"Open settings menu"
};
} // namespace
void QuickMenuActivity::taskTrampoline(void* param) {
auto* self = static_cast<QuickMenuActivity*>(param);
self->displayTaskLoop();
}
void QuickMenuActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Reset selection
selectedIndex = 0;
// Trigger first update
updateRequired = true;
xTaskCreate(&QuickMenuActivity::taskTrampoline, "QuickMenuTask",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void QuickMenuActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void QuickMenuActivity::loop() {
// Handle back button - cancel
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onCancel();
return;
}
// Handle confirm button - select current option
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
QuickMenuAction action;
switch (selectedIndex) {
case 0:
action = QuickMenuAction::DICTIONARY;
break;
case 1:
action = QuickMenuAction::ADD_BOOKMARK;
break;
case 2:
action = QuickMenuAction::CLEAR_CACHE;
break;
case 3:
default:
action = QuickMenuAction::GO_TO_SETTINGS;
break;
}
onActionSelected(action);
return;
}
// Handle navigation
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
updateRequired = true;
} else if (nextPressed) {
selectedIndex = (selectedIndex + 1) % MENU_ITEM_COUNT;
updateRequired = true;
}
}
void QuickMenuActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void QuickMenuActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Get bezel offsets
const int bezelTop = renderer.getBezelOffsetTop();
const int bezelLeft = renderer.getBezelOffsetLeft();
const int bezelRight = renderer.getBezelOffsetRight();
const int bezelBottom = renderer.getBezelOffsetBottom();
// Calculate usable content area
const int marginLeft = 20 + bezelLeft;
const int marginRight = 20 + bezelRight;
const int marginTop = 15 + bezelTop;
const int contentWidth = pageWidth - marginLeft - marginRight;
const int contentHeight = pageHeight - marginTop - 60 - bezelBottom; // 60 for button hints
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "Quick Menu", true, EpdFontFamily::BOLD);
// Select descriptions based on bookmark state
const char* const* descriptions = isPageBookmarked ? MENU_DESCRIPTIONS_REMOVE : MENU_DESCRIPTIONS_ADD;
// Draw menu items centered in content area
constexpr int itemHeight = 50; // Height for each menu item (including description)
const int startY = marginTop + (contentHeight - (MENU_ITEM_COUNT * itemHeight)) / 2;
for (int i = 0; i < MENU_ITEM_COUNT; i++) {
const int itemY = startY + i * itemHeight;
const bool isSelected = (i == selectedIndex);
// Draw selection highlight (black fill) for selected item
if (isSelected) {
renderer.fillRect(marginLeft + 10, itemY - 2, contentWidth - 20, itemHeight - 6);
}
// Draw menu item text
const char* itemText = MENU_ITEMS[i];
// For bookmark item, show different text based on state
if (i == 1) {
itemText = isPageBookmarked ? "Remove Bookmark" : "Add Bookmark";
}
renderer.drawText(UI_10_FONT_ID, marginLeft + 20, itemY, itemText, !isSelected);
renderer.drawText(SMALL_FONT_ID, marginLeft + 20, itemY + 22, descriptions[i], !isSelected);
}
// Draw help text at bottom
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,46 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
// Enum for quick menu selection
enum class QuickMenuAction { DICTIONARY, ADD_BOOKMARK, CLEAR_CACHE, GO_TO_SETTINGS };
/**
* QuickMenuActivity presents a quick access menu triggered by short power button press.
* Options:
* - "Dictionary" - Look up a word
* - "Add/Remove Bookmark" - Toggle bookmark on current page
*
* The onActionSelected callback is called with the user's choice.
* The onCancel callback is called if the user presses back.
*/
class QuickMenuActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectedIndex = 0;
bool updateRequired = false;
const std::function<void(QuickMenuAction)> onActionSelected;
const std::function<void()> onCancel;
const bool isPageBookmarked; // True if current page already has a bookmark
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
explicit QuickMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(QuickMenuAction)>& onActionSelected,
const std::function<void()>& onCancel, bool isPageBookmarked = false)
: Activity("QuickMenu", renderer, mappedInput),
onActionSelected(onActionSelected),
onCancel(onCancel),
isPageBookmarked(isPageBookmarked) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -21,11 +21,13 @@
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
#include "activities/browser/OpdsBookBrowserActivity.h"
#include "activities/home/BookmarkListActivity.h"
#include "activities/home/HomeActivity.h"
#include "activities/home/ListViewActivity.h"
#include "activities/home/MyLibraryActivity.h"
#include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h"
#include "activities/settings/ClearCacheActivity.h"
#include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h"
#include "fontIds.h"
@ -336,10 +338,13 @@ void enterDeepSleep() {
void onGoHome();
void onGoToMyLibrary();
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab);
void onGoToClearCache();
void onGoToSettings();
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
exitActivity();
enterNewActivity(
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab));
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
}
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
@ -348,7 +353,7 @@ void onGoToReaderFromList(const std::string& bookPath) {
exitActivity();
// When opening from a list, treat it like opening from Recent (will return to list view via back)
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath, MyLibraryActivity::Tab::Recent, onGoHome,
onGoToMyLibraryWithTab));
onGoToMyLibraryWithTab, onGoToClearCache, onGoToSettings));
}
// View a specific list
@ -358,6 +363,22 @@ void onGoToListView(const std::string& listName) {
new ListViewActivity(renderer, mappedInputManager, listName, onGoToMyLibrary, onGoToReaderFromList));
}
// View bookmarks for a specific book
void onGoToBookmarkList(const std::string& bookPath, const std::string& bookTitle) {
exitActivity();
enterNewActivity(new BookmarkListActivity(
renderer, mappedInputManager, bookPath, bookTitle,
onGoToMyLibrary, // On back, return to library
[bookPath](uint16_t spineIndex, uint32_t contentOffset) {
// Navigate to bookmark location in the book
// For now, just open the book (TODO: pass bookmark location to reader)
exitActivity();
enterNewActivity(new ReaderActivity(renderer, mappedInputManager, bookPath,
MyLibraryActivity::Tab::Bookmarks, onGoHome, onGoToMyLibraryWithTab,
onGoToClearCache, onGoToSettings));
}));
}
// Go to pinned list (if exists) or Lists tab
void onGoToListsOrPinned() {
exitActivity();
@ -368,7 +389,7 @@ void onGoToListsOrPinned() {
} else {
// Go to Lists tab in My Library
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
MyLibraryActivity::Tab::Lists));
onGoToBookmarkList, MyLibraryActivity::Tab::Lists));
}
}
@ -382,14 +403,19 @@ void onGoToSettings() {
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToClearCache() {
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInputManager, onGoHome));
}
void onGoToMyLibrary() {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
}
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, tab, path));
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList, tab, path));
}
void onGoToBrowser() {