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>
171 lines
6.2 KiB
C++
171 lines
6.2 KiB
C++
#include "EpubReaderChapterSelectionActivity.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
|
|
#include "MappedInputManager.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
|
|
int EpubReaderChapterSelectionActivity::getTotalItems() const { return epub->getTocItemsCount(); }
|
|
|
|
int EpubReaderChapterSelectionActivity::getPageItems() const {
|
|
// Layout constants used in renderScreen
|
|
constexpr int lineHeight = 30;
|
|
|
|
const int screenHeight = renderer.getScreenHeight();
|
|
const auto orientation = renderer.getOrientation();
|
|
// In inverted portrait, the button hints are drawn near the logical top.
|
|
// Reserve vertical space so list items do not collide with the hints.
|
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
|
const int startY = 60 + hintGutterHeight;
|
|
const int availableHeight = screenHeight - startY - lineHeight;
|
|
// Clamp to at least one item to avoid division by zero and empty paging.
|
|
return std::max(1, availableHeight / lineHeight);
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
|
|
auto* self = static_cast<EpubReaderChapterSelectionActivity*>(param);
|
|
self->displayTaskLoop();
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::onEnter() {
|
|
ActivityWithSubactivity::onEnter();
|
|
|
|
if (!epub) {
|
|
return;
|
|
}
|
|
|
|
renderingMutex = xSemaphoreCreateMutex();
|
|
|
|
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
|
if (selectorIndex == -1) {
|
|
selectorIndex = 0;
|
|
}
|
|
|
|
// Trigger first update
|
|
updateRequired = true;
|
|
xTaskCreate(&EpubReaderChapterSelectionActivity::taskTrampoline, "EpubReaderChapterSelectionActivityTask",
|
|
4096, // Stack size
|
|
this, // Parameters
|
|
1, // Priority
|
|
&displayTaskHandle // Task handle
|
|
);
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::onExit() {
|
|
ActivityWithSubactivity::onExit();
|
|
|
|
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
if (displayTaskHandle) {
|
|
vTaskDelete(displayTaskHandle);
|
|
displayTaskHandle = nullptr;
|
|
}
|
|
vSemaphoreDelete(renderingMutex);
|
|
renderingMutex = nullptr;
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::loop() {
|
|
if (subActivity) {
|
|
subActivity->loop();
|
|
return;
|
|
}
|
|
|
|
const int pageItems = getPageItems();
|
|
const int totalItems = getTotalItems();
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
|
if (newSpineIndex == -1) {
|
|
onGoBack();
|
|
} else {
|
|
onSelectSpineIndex(newSpineIndex);
|
|
}
|
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
onGoBack();
|
|
}
|
|
|
|
buttonNavigator.onNextRelease([this, totalItems] {
|
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
|
updateRequired = true;
|
|
});
|
|
|
|
buttonNavigator.onPreviousRelease([this, totalItems] {
|
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
|
updateRequired = true;
|
|
});
|
|
|
|
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
|
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
|
updateRequired = true;
|
|
});
|
|
|
|
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
|
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
|
updateRequired = true;
|
|
});
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
|
|
while (true) {
|
|
if (updateRequired && !subActivity) {
|
|
updateRequired = false;
|
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
|
renderScreen();
|
|
xSemaphoreGive(renderingMutex);
|
|
}
|
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
|
}
|
|
}
|
|
|
|
void EpubReaderChapterSelectionActivity::renderScreen() {
|
|
renderer.clearScreen();
|
|
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
const auto orientation = renderer.getOrientation();
|
|
// Landscape orientation: reserve a horizontal gutter for button hints.
|
|
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
|
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
|
// Inverted portrait: reserve vertical space for hints at the top.
|
|
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;
|
|
const int pageItems = getPageItems();
|
|
const int totalItems = getTotalItems();
|
|
|
|
// Manual centering to honor content gutters.
|
|
const int titleX =
|
|
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Chapter", EpdFontFamily::BOLD)) / 2;
|
|
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Chapter", true, EpdFontFamily::BOLD);
|
|
|
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
|
// Highlight only the content area, not the hint gutters.
|
|
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);
|
|
|
|
auto item = epub->getTocItem(itemIndex);
|
|
|
|
// Indent per TOC level while keeping content within the gutter-safe region.
|
|
const int indentSize = contentX + 20 + (item.level - 1) * 15;
|
|
const std::string chapterName =
|
|
renderer.truncatedText(UI_10_FONT_ID, item.title.c_str(), contentWidth - 40 - indentSize);
|
|
|
|
renderer.drawText(UI_10_FONT_ID, indentSize, displayY, chapterName.c_str(), !isSelected);
|
|
}
|
|
|
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
|
|
renderer.displayBuffer();
|
|
}
|