This PR unifies navigation handling & adds system-wide support for continuous navigation. ## Summary Holding down a navigation button now continuously advances through items until the button is released. This removes the need for repeated press-and-release actions and makes navigation faster and smoother, especially in long menus or documents. When page-based navigation is available, it will navigate through pages. If not, it will progress through menu items or similar list-based UI elements. Additionally, this PR fixes inconsistencies in wrap-around behavior and navigation index calculations. Places where the navigation system was updated: - Home Page - Settings Pages - My Library Page - WiFi Selection Page - OPDS Browser Page - Keyboard - File Transfer Page - XTC Chapter Selector Page - EPUB Chapter Selector Page I’ve tested this on the device as much as possible and tried to match the existing behavior. Please let me know if I missed anything. Thanks 🙏  --- Following the request from @osteotek and @daveallie for system-wide support, the old PR (#379) has been closed in favor of this consolidated, system-wide implementation. --- ### AI Usage Did you use AI tools to help write this code? _**PARTIALLY**_ --------- Co-authored-by: Dave Allie <dave@daveallie.com>
150 lines
5.5 KiB
C++
150 lines
5.5 KiB
C++
#include "EpubReaderMenuActivity.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
|
|
#include "MappedInputManager.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
|
|
void EpubReaderMenuActivity::onEnter() {
|
|
ActivityWithSubactivity::onEnter();
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
updateRequired = true;
|
|
|
|
xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle);
|
|
}
|
|
|
|
void EpubReaderMenuActivity::onExit() {
|
|
ActivityWithSubactivity::onExit();
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
if (displayTaskHandle) {
|
|
vTaskDelete(displayTaskHandle);
|
|
displayTaskHandle = nullptr;
|
|
}
|
|
vSemaphoreDelete(renderingMutex);
|
|
renderingMutex = nullptr;
|
|
}
|
|
|
|
void EpubReaderMenuActivity::taskTrampoline(void* param) {
|
|
auto* self = static_cast<EpubReaderMenuActivity*>(param);
|
|
self->displayTaskLoop();
|
|
}
|
|
|
|
void EpubReaderMenuActivity::displayTaskLoop() {
|
|
while (true) {
|
|
if (updateRequired && !subActivity) {
|
|
updateRequired = false;
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
renderScreen();
|
|
xSemaphoreGive(renderingMutex);
|
|
}
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
}
|
|
}
|
|
|
|
void EpubReaderMenuActivity::loop() {
|
|
if (subActivity) {
|
|
subActivity->loop();
|
|
return;
|
|
}
|
|
|
|
// Handle navigation
|
|
buttonNavigator.onNext([this] {
|
|
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
|
updateRequired = true;
|
|
});
|
|
|
|
buttonNavigator.onPrevious([this] {
|
|
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
|
updateRequired = true;
|
|
});
|
|
|
|
// Use local variables for items we need to check after potential deletion
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
const auto selectedAction = menuItems[selectedIndex].action;
|
|
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
|
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
|
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
|
updateRequired = true;
|
|
return;
|
|
}
|
|
|
|
// 1. Capture the callback and action locally
|
|
auto actionCallback = onAction;
|
|
|
|
// 2. Execute the callback
|
|
actionCallback(selectedAction);
|
|
|
|
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
|
|
return;
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
// Return the pending orientation to the parent so it can apply on exit.
|
|
onBack(pendingOrientation);
|
|
return; // Also return here just in case
|
|
}
|
|
}
|
|
|
|
void EpubReaderMenuActivity::renderScreen() {
|
|
renderer.clearScreen();
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto orientation = renderer.getOrientation();
|
|
// Landscape orientation: button hints are drawn along a vertical edge, so we
|
|
// reserve a horizontal gutter to prevent overlap with menu content.
|
|
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
|
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
|
// Inverted portrait: button hints appear near the logical top, so we reserve
|
|
// vertical space to keep the header and list clear.
|
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
|
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
|
// Landscape CW places hints on the left edge; CCW keeps them on the right.
|
|
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
|
const int contentWidth = pageWidth - hintGutterWidth;
|
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
|
const int contentY = hintGutterHeight;
|
|
|
|
// Title
|
|
const std::string truncTitle =
|
|
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), contentWidth - 40, EpdFontFamily::BOLD);
|
|
// Manual centering so we can respect the content gutter.
|
|
const int titleX =
|
|
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, truncTitle.c_str(), EpdFontFamily::BOLD)) / 2;
|
|
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, truncTitle.c_str(), true, EpdFontFamily::BOLD);
|
|
|
|
// Progress summary
|
|
std::string progressLine;
|
|
if (totalPages > 0) {
|
|
progressLine = "Chapter: " + std::to_string(currentPage) + "/" + std::to_string(totalPages) + " pages | ";
|
|
}
|
|
progressLine += "Book: " + std::to_string(bookProgressPercent) + "%";
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 45, progressLine.c_str());
|
|
|
|
// Menu Items
|
|
const int startY = 75 + contentY;
|
|
constexpr int lineHeight = 30;
|
|
|
|
for (size_t i = 0; i < menuItems.size(); ++i) {
|
|
const int displayY = startY + (i * lineHeight);
|
|
const bool isSelected = (static_cast<int>(i) == selectedIndex);
|
|
|
|
if (isSelected) {
|
|
// Highlight only the content area so we don't paint over hint gutters.
|
|
renderer.fillRect(contentX, displayY, contentWidth - 1, lineHeight, true);
|
|
}
|
|
|
|
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, menuItems[i].label.c_str(), !isSelected);
|
|
|
|
if (menuItems[i].action == MenuAction::ROTATE_SCREEN) {
|
|
// Render current orientation value on the right edge of the content area.
|
|
const auto value = orientationLabels[pendingOrientation];
|
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
|
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
|
}
|
|
}
|
|
|
|
// Footer / Hints
|
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
renderer.displayBuffer();
|
|
}
|