Merge remote-tracking branch 'origin/develop' into feat-deferred-refresh
This commit is contained in:
@@ -22,3 +22,20 @@ void Activity::startActivityForResult(std::unique_ptr<Activity>&& activity, Acti
|
||||
void Activity::setResult(ActivityResult&& result) { this->result = std::move(result); }
|
||||
|
||||
void Activity::finish() { activityManager.popActivity(); }
|
||||
|
||||
Activity::ListTouchResult Activity::handleListTouch(int& selectedIndex, const int itemCount, const int listTop,
|
||||
const int listHeight, const bool hasSubtitle) {
|
||||
int touched = -1;
|
||||
if (mappedInput.wasListItemTouchedDown(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
if (selectedIndex != touched) {
|
||||
selectedIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
return ListTouchResult::Consumed;
|
||||
}
|
||||
if (mappedInput.wasListItemTapped(touched, itemCount, selectedIndex, listTop, listHeight, hasSubtitle)) {
|
||||
selectedIndex = touched;
|
||||
return ListTouchResult::Activated;
|
||||
}
|
||||
return ListTouchResult::None;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ class Activity {
|
||||
virtual bool skipLoopDelay() { return false; }
|
||||
virtual bool preventAutoSleep() { return false; }
|
||||
virtual bool isReaderActivity() const { return false; }
|
||||
virtual bool isHomeActivity() const { return false; }
|
||||
virtual bool handleHomeGesture() { return false; }
|
||||
virtual ScreenshotInfo getScreenshotInfo() const { return {}; }
|
||||
|
||||
// Start a new activity without destroying the current one
|
||||
@@ -60,4 +62,16 @@ class Activity {
|
||||
// TODO: remove this in near future
|
||||
void onGoHome(HomeMenuItem item = HomeMenuItem::NONE);
|
||||
void onSelectBook(const std::string& path);
|
||||
|
||||
protected:
|
||||
enum class ListTouchResult : uint8_t {
|
||||
None, // touch did not hit the list
|
||||
Consumed, // touchdown moved the highlight (repaint already requested)
|
||||
Activated // tap landed on a row: selectedIndex is updated, caller activates it
|
||||
};
|
||||
|
||||
// Shared touch handling for selectable list screens: touchdown highlights the
|
||||
// touched row, a tap selects and reports Activated. The caller supplies the
|
||||
// list band and runs its own activate action on Activated.
|
||||
ListTouchResult handleListTouch(int& selectedIndex, int itemCount, int listTop, int listHeight, bool hasSubtitle);
|
||||
};
|
||||
|
||||
@@ -22,12 +22,17 @@
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
#if defined(configNUM_CORES) && configNUM_CORES > 1
|
||||
constexpr BaseType_t renderTaskCore = 1;
|
||||
#else
|
||||
constexpr BaseType_t renderTaskCore = 0;
|
||||
#endif
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -61,6 +66,14 @@ void ActivityManager::renderTaskLoop() {
|
||||
|
||||
void ActivityManager::loop() {
|
||||
if (currentActivity) {
|
||||
if (!currentActivity->isHomeActivity() && mappedInput.wasHomeGesture()) {
|
||||
if (currentActivity->handleHomeGesture()) {
|
||||
return;
|
||||
}
|
||||
goHome();
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: do not hold a lock here, the loop() method must be responsible for acquire one if needed
|
||||
currentActivity->loop();
|
||||
}
|
||||
|
||||
@@ -2,26 +2,44 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "components/icons/search24.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/BookCacheUtils.h"
|
||||
#include "util/OpdsFilename.h"
|
||||
#include "util/StringUtils.h"
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
namespace {
|
||||
constexpr int PAGE_ITEMS = 23;
|
||||
constexpr int HEADER_Y = 15;
|
||||
constexpr int HEADER_X = 16;
|
||||
constexpr int SEARCH_ICON_SIZE = 24;
|
||||
constexpr int SEARCH_ICON_MARGIN = 14;
|
||||
constexpr int SEARCH_ICON_Y = 15;
|
||||
constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5;
|
||||
constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000;
|
||||
|
||||
Rect searchIconRect(const GfxRenderer& renderer) {
|
||||
return Rect{renderer.getScreenWidth() - SEARCH_ICON_SIZE - SEARCH_ICON_MARGIN, SEARCH_ICON_Y, SEARCH_ICON_SIZE + 8,
|
||||
SEARCH_ICON_SIZE + 8};
|
||||
}
|
||||
|
||||
bool contains(const Rect& rect, const int x, const int y) {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
@@ -69,7 +87,9 @@ void OpdsBookBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == BrowserState::ERROR) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
state = BrowserState::LOADING;
|
||||
statusMessage = tr(STR_LOADING);
|
||||
@@ -94,18 +114,59 @@ void OpdsBookBrowserActivity::loop() {
|
||||
if (state == BrowserState::DOWNLOADING) return;
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
navigateBack();
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
if (!searchTemplate.empty() && selectorIndex == 0) launchSearch();
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!searchTemplate.empty() && mappedInput.wasScreenTapped(tx, ty) && contains(searchIconRect(renderer), tx, ty)) {
|
||||
launchSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!entries.empty()) {
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, /*top=*/60, /*rowStep=*/30, PAGE_ITEMS);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = selectorIndex / PAGE_ITEMS * PAGE_ITEMS + row;
|
||||
if (touched >= 0 && touched < static_cast<int>(entries.size())) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touched) {
|
||||
selectorIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touched;
|
||||
activateSelected();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size());
|
||||
requestUpdate();
|
||||
@@ -133,7 +194,14 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
|
||||
// Show server name in header if available, otherwise generic title
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
const int headerRightInset = searchTemplate.empty() ? HEADER_X : (SEARCH_ICON_SIZE + SEARCH_ICON_MARGIN * 2 + 8);
|
||||
const auto clippedHeader =
|
||||
renderer.truncatedText(UI_12_FONT_ID, headerTitle, pageWidth - HEADER_X - headerRightInset, EpdFontFamily::BOLD);
|
||||
renderer.drawText(UI_12_FONT_ID, HEADER_X, HEADER_Y, clippedHeader.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (!searchTemplate.empty()) {
|
||||
const auto rect = searchIconRect(renderer);
|
||||
renderer.drawIcon(Search24Icon.bits, rect.x + 4, rect.y + 4, Search24Icon.w);
|
||||
}
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
@@ -146,6 +214,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
if (state == BrowserState::ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG));
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
|
||||
if (mappedInput.hasTouch()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 40, tr(STR_TAP_TO_RETRY));
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
@@ -279,8 +350,26 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
// Build full download URL relative to the current feed, not the root server URL
|
||||
const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath);
|
||||
std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href);
|
||||
std::string filename =
|
||||
"/" + StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) + ".epub";
|
||||
// opdsDownloadFolder is already a null-terminated char[64]; use it directly —
|
||||
// no std::string copy. exists()/mkdir() take const char*.
|
||||
const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root
|
||||
bool haveFolder = folder[0] != '\0';
|
||||
if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) {
|
||||
// exists()-guard first: mkdir's return-on-existing is unconfirmed, and every
|
||||
// existing caller checks exists() before mkdir. On real failure, fall back
|
||||
// to SD root so the download is never lost.
|
||||
LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder);
|
||||
haveFolder = false;
|
||||
}
|
||||
|
||||
// downloadToFile() needs a std::string, and titles are unbounded (a fixed
|
||||
// char[] would truncate). Cold path (a multi-second download follows), so one
|
||||
// reserve'd, in-place-appended owning string is the right call.
|
||||
std::string filename;
|
||||
filename.reserve(96);
|
||||
if (haveFolder) filename += folder;
|
||||
filename += '/';
|
||||
filename += opdsBookFilename(book.author, book.title, static_cast<OpdsFilenameFormat>(SETTINGS.opdsFilenameFormat));
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
int lastRenderedPercent = -1;
|
||||
|
||||
@@ -20,7 +20,9 @@ void CrashActivity::onEnter() {
|
||||
}
|
||||
|
||||
void CrashActivity::loop() {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +205,12 @@ void FileBrowserActivity::loop() {
|
||||
|
||||
const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing;
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (lockNextConfirmRelease) {
|
||||
lockNextConfirmRelease = false;
|
||||
return;
|
||||
@@ -234,6 +238,11 @@ void FileBrowserActivity::loop() {
|
||||
const std::string fullPath = cleanBasePath + entry;
|
||||
|
||||
auto handler = [this, fullPath](const ActivityResult& res) {
|
||||
// The confirmation popup acts on button press; if that button is still
|
||||
// held when we resume, swallow its release so it doesn't also act here
|
||||
// (Back would go up a directory, Confirm would open the selection).
|
||||
lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back);
|
||||
lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm);
|
||||
if (!res.isCancelled) {
|
||||
LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str());
|
||||
if (removeDirFile(fullPath)) {
|
||||
@@ -273,6 +282,19 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, static_cast<int>(files.size()), contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -303,6 +325,18 @@ void FileBrowserActivity::loop() {
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(files.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
requestUpdate();
|
||||
|
||||
@@ -168,6 +168,34 @@ void HomeActivity::freeCoverBuffer() {
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const int menuCount = getMenuItemCount();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
auto activateSelection = [this] {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
return;
|
||||
}
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
buttonNavigator.onNext([this, menuCount] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
@@ -179,31 +207,72 @@ void HomeActivity::loop() {
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else {
|
||||
const int menuIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
switch (indexToMenuItem(menuIndex, hasOpdsServers)) {
|
||||
case HomeMenuItem::FILE_BROWSER:
|
||||
onFileBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::RECENTS:
|
||||
onRecentsOpen();
|
||||
break;
|
||||
case HomeMenuItem::OPDS_BROWSER:
|
||||
onOpdsBrowserOpen();
|
||||
break;
|
||||
case HomeMenuItem::FILE_TRANSFER:
|
||||
onFileTransferOpen();
|
||||
break;
|
||||
case HomeMenuItem::SETTINGS_MENU:
|
||||
onSettingsOpen();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true;
|
||||
|
||||
// Back is otherwise unused on the home menu: open the most recently read
|
||||
// book directly (recentBooks is most-recent-first and already pruned of
|
||||
// files missing from the SD card). backPressSeen guards against the stale
|
||||
// release of the Back press that closed the previous activity.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && !recentBooks.empty()) {
|
||||
onSelectBook(recentBooks[0].path);
|
||||
return;
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (!recentBooks.empty() && mappedInput.wasScreenTouchDown(tx, ty) && tx >= 0 && tx < renderer.getScreenWidth() &&
|
||||
ty >= metrics.homeTopPadding && ty < metrics.homeTopPadding + metrics.homeCoverTileHeight) {
|
||||
if (selectorIndex != 0) {
|
||||
selectorIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recentBooks.empty() &&
|
||||
mappedInput.wasTapInRect(0, metrics.homeTopPadding, renderer.getScreenWidth(), metrics.homeCoverTileHeight)) {
|
||||
selectorIndex = 0;
|
||||
activateSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuTop = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
|
||||
const int renderedMenuSelection =
|
||||
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size();
|
||||
const int renderedMenuCount =
|
||||
menuCount - (metrics.homeContinueReadingInMenu ? 0 : static_cast<int>(recentBooks.size()));
|
||||
int menuRow = -1;
|
||||
const auto menuTouch = mappedInput.rowTouch(menuRow, menuTop, metrics.menuRowHeight + metrics.menuSpacing,
|
||||
renderedMenuCount, 0, INT32_MAX, metrics.menuRowHeight);
|
||||
if (menuTouch != MappedInputManager::RowTouch::None) {
|
||||
const int touchedIndex =
|
||||
metrics.homeContinueReadingInMenu ? menuRow : menuRow + static_cast<int>(recentBooks.size());
|
||||
if (menuTouch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touchedIndex) {
|
||||
selectorIndex = touchedIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touchedIndex;
|
||||
activateSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelection();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +325,8 @@ void HomeActivity::render(RenderLock&&) {
|
||||
[&menuItems](int index) { return std::string(menuItems[index]); },
|
||||
[&menuIcons](int index) { return menuIcons[index]; });
|
||||
|
||||
const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
const auto labels = mappedInput.mapLabels(recentBooks.empty() ? "" : tr(STR_RESUME), tr(STR_SELECT), tr(STR_DIR_UP),
|
||||
tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -18,6 +18,9 @@ class HomeActivity final : public Activity {
|
||||
bool hasOpdsServers = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
// Home can be entered while Back is still held (e.g. leaving Settings with
|
||||
// Back): ignore that stale release until a fresh press is seen here.
|
||||
bool backPressSeen = false;
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
size_t coverBufferSize = 0; // Bytes allocated to coverBuffer
|
||||
// Logical rect last passed to drawRecentBookCover. The cover snapshot only
|
||||
@@ -77,4 +80,5 @@ class HomeActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool isHomeActivity() const override { return true; }
|
||||
};
|
||||
|
||||
@@ -43,6 +43,10 @@ void RecentBooksActivity::onExit() {
|
||||
|
||||
void RecentBooksActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// After a long-press has fired, swallow input until Confirm is physically released
|
||||
// (so the release doesn't also open the book; re-arm only once the button is up).
|
||||
@@ -71,11 +75,34 @@ void RecentBooksActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
int touchSel = static_cast<int>(selectorIndex);
|
||||
const auto listTouch =
|
||||
handleListTouch(touchSel, static_cast<int>(recentBooks.size()), contentTop, contentHeight, true);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectorIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) {
|
||||
LOG_DBG("RBA", "Tapped recent book: %s", recentBooks[selectorIndex].path.c_str());
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoHome();
|
||||
}
|
||||
|
||||
int listSize = static_cast<int>(recentBooks.size());
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectorIndex), listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, listSize] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(static_cast<int>(selectorIndex), listSize);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SilentRestart.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
constexpr const char* HOSTNAME = "crosspoint";
|
||||
@@ -110,12 +110,12 @@ void CalibreConnectActivity::loop() {
|
||||
LOG_DBG("CAL", "WARNING: %lu ms gap since last handleClient", timeSinceLastHandleClient);
|
||||
}
|
||||
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
constexpr int MAX_ITERATIONS = 80;
|
||||
for (int i = 0; i < MAX_ITERATIONS && webServer->isRunning(); i++) {
|
||||
webServer->handleClient();
|
||||
if ((i & 0x07) == 0x07) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
if ((i & 0x0F) == 0x0F) {
|
||||
yield();
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
@@ -17,6 +16,7 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/QrUtils.h"
|
||||
#include "util/TaskWatchdog.h"
|
||||
|
||||
namespace {
|
||||
// AP Mode configuration
|
||||
@@ -328,7 +328,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
}
|
||||
|
||||
// Reset watchdog BEFORE processing - HTTP header parsing can be slow
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
|
||||
// Process HTTP requests in tight loop for maximum throughput
|
||||
// More iterations = more data processed per main loop cycle
|
||||
@@ -337,7 +337,7 @@ void CrossPointWebServerActivity::loop() {
|
||||
webServer->handleClient();
|
||||
// Reset watchdog every 32 iterations
|
||||
if ((i & 0x1F) == 0x1F) {
|
||||
esp_task_wdt_reset();
|
||||
resetTaskWatchdogIfSubscribed();
|
||||
}
|
||||
// Yield and check for exit button every 64 iterations
|
||||
if ((i & 0x3F) == 0x3F) {
|
||||
|
||||
@@ -24,6 +24,16 @@ void NetworkModeSelectionActivity::onEnter() {
|
||||
void NetworkModeSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void NetworkModeSelectionActivity::loop() {
|
||||
auto selectCurrent = [this] {
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
} else if (selectedIndex == 2) {
|
||||
mode = NetworkMode::CREATE_HOTSPOT;
|
||||
}
|
||||
onModeSelected(mode);
|
||||
};
|
||||
|
||||
// Handle back button - cancel
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onCancel();
|
||||
@@ -32,16 +42,24 @@ void NetworkModeSelectionActivity::loop() {
|
||||
|
||||
// Handle confirm button - select current option
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
NetworkMode mode = NetworkMode::JOIN_NETWORK;
|
||||
if (selectedIndex == 1) {
|
||||
mode = NetworkMode::CONNECT_CALIBRE;
|
||||
} else if (selectedIndex == 2) {
|
||||
mode = NetworkMode::CREATE_HOTSPOT;
|
||||
}
|
||||
onModeSelected(mode);
|
||||
selectCurrent();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
switch (handleListTouch(selectedIndex, MENU_ITEM_COUNT, contentTop, contentHeight, true)) {
|
||||
case ListTouchResult::Activated:
|
||||
selectCurrent();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT);
|
||||
|
||||
@@ -354,6 +354,11 @@ void WifiSelectionActivity::attemptConnection() {
|
||||
WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID
|
||||
delay(100);
|
||||
|
||||
// Scan all channels so networks with multiple APs use the strongest matching
|
||||
// BSSID instead of the first match found by the framework's default fast scan.
|
||||
WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
|
||||
WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL);
|
||||
|
||||
// Set hostname so routers show "CrossPoint-Reader-AABBCCDDEEFF" instead of "esp32-XXXXXXXXXXXX"
|
||||
String mac = WiFi.macAddress();
|
||||
mac.replace(":", "");
|
||||
@@ -382,6 +387,16 @@ void WifiSelectionActivity::checkConnectionStatus() {
|
||||
connectedIP = ipStr;
|
||||
autoConnecting = false;
|
||||
|
||||
#if defined(ENABLE_SERIAL_LOG) && LOG_LEVEL >= 2
|
||||
uint8_t connectedBssid[6] = {};
|
||||
WiFi.BSSID(connectedBssid);
|
||||
LOG_DBG("WIFI", "Connected BSSID: %02x:%02x:%02x:%02x:%02x:%02x, channel: %d, RSSI: %d dBm",
|
||||
static_cast<unsigned>(connectedBssid[0]), static_cast<unsigned>(connectedBssid[1]),
|
||||
static_cast<unsigned>(connectedBssid[2]), static_cast<unsigned>(connectedBssid[3]),
|
||||
static_cast<unsigned>(connectedBssid[4]), static_cast<unsigned>(connectedBssid[5]), WiFi.channel(),
|
||||
WiFi.RSSI());
|
||||
#endif
|
||||
|
||||
// Sync RTC from NTP on the first successful WiFi connection only. The DS3231
|
||||
// drifts ~2 ppm so one sync is enough; users can force a re-sync from
|
||||
// Settings > Customise Status Bar > Sync clock now.
|
||||
@@ -502,6 +517,34 @@ void WifiSelectionActivity::loop() {
|
||||
|
||||
// Handle save prompt state
|
||||
if (state == WifiSelectionState::SAVE_PROMPT) {
|
||||
{
|
||||
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int buttonY = screen.y + (screen.height - height * 3) / 2 + 80;
|
||||
constexpr int buttonWidth = 60;
|
||||
constexpr int buttonSpacing = 30;
|
||||
const int startX = screen.x + (screen.width - (buttonWidth * 2 + buttonSpacing)) / 2;
|
||||
int touchedOption = -1;
|
||||
const auto touch = mappedInput.colTouch(touchedOption, startX - 8, buttonWidth + buttonSpacing, 2, buttonY - 8,
|
||||
buttonY + height + 8, buttonWidth + 16);
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (savePromptSelection != touchedOption) {
|
||||
savePromptSelection = touchedOption;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (touch == MappedInputManager::RowTouch::Tap) {
|
||||
savePromptSelection = touchedOption;
|
||||
if (savePromptSelection == 0) {
|
||||
RenderLock lock(*this);
|
||||
WIFI_STORE.addCredential(selectedSSID, enteredPassword);
|
||||
}
|
||||
onComplete(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
if (savePromptSelection > 0) {
|
||||
@@ -531,6 +574,39 @@ void WifiSelectionActivity::loop() {
|
||||
|
||||
// Handle forget prompt state (connection failed with saved credentials)
|
||||
if (state == WifiSelectionState::FORGET_PROMPT) {
|
||||
{
|
||||
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int buttonY = screen.y + (screen.height - height * 3) / 2 + 80;
|
||||
constexpr int buttonWidth = 120;
|
||||
constexpr int buttonSpacing = 30;
|
||||
const int startX = screen.x + (screen.width - (buttonWidth * 2 + buttonSpacing)) / 2;
|
||||
int touchedOption = -1;
|
||||
const auto touch = mappedInput.colTouch(touchedOption, startX - 8, buttonWidth + buttonSpacing, 2, buttonY - 8,
|
||||
buttonY + height + 8, buttonWidth + 16);
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (forgetPromptSelection != touchedOption) {
|
||||
forgetPromptSelection = touchedOption;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (touch == MappedInputManager::RowTouch::Tap) {
|
||||
forgetPromptSelection = touchedOption;
|
||||
if (forgetPromptSelection == 1) {
|
||||
RenderLock lock(*this);
|
||||
WIFI_STORE.removeCredential(selectedSSID);
|
||||
const auto network = find_if(networks.begin(), networks.end(),
|
||||
[this](const WifiNetworkInfo& net) { return net.ssid == selectedSSID; });
|
||||
if (network != networks.end()) {
|
||||
network->hasSavedPassword = false;
|
||||
}
|
||||
}
|
||||
startWifiScan();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
if (forgetPromptSelection > 0) {
|
||||
@@ -626,6 +702,35 @@ void WifiSelectionActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!networks.empty()) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop =
|
||||
screen.y + metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing * 2;
|
||||
int touchSel = static_cast<int>(selectedNetworkIndex);
|
||||
const auto listTouch =
|
||||
handleListTouch(touchSel, static_cast<int>(networks.size()), contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedNetworkIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) selectNetwork(selectedNetworkIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = GUI.getListPageItems(contentHeight, false);
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedNetworkIndex = ButtonNavigator::nextPageIndex(selectedNetworkIndex, networks.size(), pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedNetworkIndex = ButtonNavigator::previousPageIndex(selectedNetworkIndex, networks.size(), pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedNetworkIndex = ButtonNavigator::nextIndex(selectedNetworkIndex, networks.size());
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/HtmlToPlainText.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// Longest measurable/drawable span. Wrapped lines stay under the screen width
|
||||
// (far below this); only pathological unbreakable tokens are split at this cap.
|
||||
constexpr size_t MAX_LINE_BYTES = 191;
|
||||
|
||||
// Body text left/right inset, matching the reader's default feel.
|
||||
constexpr int SIDE_PADDING = 20;
|
||||
|
||||
} // namespace
|
||||
|
||||
void DictionaryDefinitionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
// Normalize StarDict multi-type separators so the wrap loop and the
|
||||
// C-string font APIs below both see the whole definition.
|
||||
std::replace(definition.begin(), definition.end(), '\0', '\n');
|
||||
definition = htmlToPlainText(definition);
|
||||
wrapText();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
int DictionaryDefinitionActivity::measureSpan(const int fontId, const char* text, size_t len) const {
|
||||
char buf[MAX_LINE_BYTES + 1];
|
||||
len = std::min(len, MAX_LINE_BYTES);
|
||||
memcpy(buf, text, len);
|
||||
buf[len] = '\0';
|
||||
return renderer.getTextAdvanceX(fontId, buf, EpdFontFamily::REGULAR);
|
||||
}
|
||||
|
||||
// Greedy word-wrap of `definition` into byte spans. '\n' breaks lines (blank
|
||||
// lines survive as paragraph spacing; NULs from multi-type StarDict entries
|
||||
// were normalized to newlines in onEnter); '\r' is dropped by treating it as
|
||||
// a space at a token edge.
|
||||
void DictionaryDefinitionActivity::wrapText() {
|
||||
lines.clear();
|
||||
lines.reserve(definition.size() / 32 + 8);
|
||||
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
// SD-card fonts: merge every definition codepoint into the persistent
|
||||
// advance table up front. Otherwise each unseen codepoint measured below
|
||||
// falls back to an on-demand glyph load from SD (8-slot overflow ring).
|
||||
renderer.ensureSdCardFontReady(fontId, definition.c_str(), 0x01 /* REGULAR */);
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscape = orientation == GfxRenderer::Orientation::LandscapeClockwise ||
|
||||
orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = isLandscape ? metrics.sideButtonHintsWidth : 0;
|
||||
const int maxWidth = renderer.getScreenWidth() - hintGutterWidth - 2 * SIDE_PADDING;
|
||||
const int spaceWidth = renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR);
|
||||
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
const int topArea = (isInverted ? metrics.buttonHintsHeight : 0) + metrics.topPadding + metrics.headerHeight;
|
||||
const int bottomArea = metrics.buttonHintsHeight + metrics.verticalSpacing;
|
||||
linesPerPage = std::max(1, (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight);
|
||||
|
||||
const char* text = definition.c_str();
|
||||
const uint32_t n = static_cast<uint32_t>(definition.size());
|
||||
uint32_t lineStart = 0;
|
||||
uint32_t lineEnd = 0; // one past the last token byte on the current line
|
||||
int lineWidth = 0;
|
||||
|
||||
const auto flushLine = [&](uint32_t nextStart) {
|
||||
lines.push_back({lineStart, static_cast<uint16_t>(lineEnd - lineStart)});
|
||||
lineStart = nextStart;
|
||||
lineEnd = nextStart;
|
||||
lineWidth = 0;
|
||||
};
|
||||
|
||||
uint32_t i = 0;
|
||||
while (i < n) {
|
||||
const char c = text[i];
|
||||
if (c == '\n' || c == '\0') {
|
||||
flushLine(i + 1);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c == ' ' || c == '\t' || c == '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Token: run of non-whitespace bytes, capped at the measure buffer.
|
||||
const uint32_t tokenStart = i;
|
||||
while (i < n && text[i] != ' ' && text[i] != '\t' && text[i] != '\r' && text[i] != '\n' && text[i] != '\0' &&
|
||||
i - tokenStart < MAX_LINE_BYTES) {
|
||||
i++;
|
||||
}
|
||||
// If the byte cap cut the token mid-UTF-8-sequence, back off to the last
|
||||
// complete codepoint so measure/draw never see a partial sequence. A
|
||||
// natural stop lands on whitespace or the terminating NUL, never on a
|
||||
// continuation byte, so this is a no-op there.
|
||||
while (i - tokenStart > 1 && (text[i] & 0xC0) == 0x80) i--;
|
||||
const uint32_t tokenLen = i - tokenStart;
|
||||
const int tokenWidth = measureSpan(fontId, text + tokenStart, tokenLen);
|
||||
|
||||
if (lineEnd == lineStart) {
|
||||
lineStart = tokenStart;
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth = tokenWidth;
|
||||
} else if (lineWidth + spaceWidth + tokenWidth <= maxWidth &&
|
||||
tokenStart + tokenLen - lineStart <= UINT16_MAX) { // span len must fit Line::len
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth += spaceWidth + tokenWidth;
|
||||
} else {
|
||||
flushLine(tokenStart);
|
||||
lineEnd = tokenStart + tokenLen;
|
||||
lineWidth = tokenWidth;
|
||||
}
|
||||
|
||||
// An unbreakable token wider than the screen is now alone on the line
|
||||
// (any previous content was flushed above): split it at the widest
|
||||
// fitting UTF-8 boundary and carry the remainder forward.
|
||||
while (lineWidth > maxWidth && lineEnd - lineStart > 1) {
|
||||
const uint32_t len = lineEnd - lineStart;
|
||||
uint32_t lastFit = 0;
|
||||
for (uint32_t f = 1; f <= len; f++) {
|
||||
if (f == len || (text[lineStart + f] & 0xC0) != 0x80) { // codepoint boundary
|
||||
if (measureSpan(fontId, text + lineStart, f) > maxWidth) break;
|
||||
lastFit = f;
|
||||
}
|
||||
}
|
||||
if (lastFit == 0) {
|
||||
// Even a single over-wide glyph must make progress; consume its whole
|
||||
// UTF-8 sequence rather than splitting it into invalid fragments.
|
||||
lastFit = 1;
|
||||
while (lastFit < len && (text[lineStart + lastFit] & 0xC0) == 0x80) lastFit++;
|
||||
}
|
||||
const uint32_t rest = lineStart + lastFit;
|
||||
lineEnd = rest;
|
||||
flushLine(rest);
|
||||
lineEnd = rest + (len - lastFit);
|
||||
lineWidth = measureSpan(fontId, text + lineStart, lineEnd - lineStart);
|
||||
}
|
||||
}
|
||||
if (lineEnd > lineStart) flushLine(n);
|
||||
|
||||
// Trim trailing blank lines so the last page is not empty padding.
|
||||
while (!lines.empty() && lines.back().len == 0) lines.pop_back();
|
||||
|
||||
totalPages = std::max(1, (static_cast<int>(lines.size()) + linesPerPage - 1) / linesPerPage);
|
||||
currentPage = 0;
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Same tap zones as the reader page turns: left third = previous page,
|
||||
// the rest = next. Back is the usual left-edge swipe.
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (tx < renderer.getScreenWidth() / 3) {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
}
|
||||
} else if (currentPage + 1 < totalPages) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
if (currentPage + 1 < totalPages) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Draws the current page's line spans (copied into a stack buffer for NUL
|
||||
// termination). Called twice per render: once in font-cache scan mode, once
|
||||
// for the real paint.
|
||||
void DictionaryDefinitionActivity::drawBody(const int fontId, const int x, const int startY) const {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
char buf[MAX_LINE_BYTES + 1];
|
||||
const int firstLine = currentPage * linesPerPage;
|
||||
const int lastLine = std::min(firstLine + linesPerPage, static_cast<int>(lines.size()));
|
||||
for (int i = firstLine; i < lastLine; i++) {
|
||||
if (lines[i].len == 0) continue;
|
||||
const size_t len = std::min(static_cast<size_t>(lines[i].len), MAX_LINE_BYTES);
|
||||
memcpy(buf, definition.c_str() + lines[i].start, len);
|
||||
buf[len] = '\0';
|
||||
renderer.drawText(fontId, x, startY + (i - firstLine) * lineHeight, buf);
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isInverted ? metrics.buttonHintsHeight : 0;
|
||||
|
||||
// Header: matched headword left, page counter right.
|
||||
const int headerY = contentY + metrics.topPadding + 10;
|
||||
renderer.drawText(UI_12_FONT_ID, contentX + SIDE_PADDING, headerY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||
if (totalPages > 1) {
|
||||
char counter[16];
|
||||
snprintf(counter, sizeof(counter), "%d/%d", currentPage + 1, totalPages);
|
||||
const int counterWidth = renderer.getTextWidth(UI_10_FONT_ID, counter);
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - SIDE_PADDING - counterWidth, headerY, counter);
|
||||
}
|
||||
|
||||
// Body: two-pass draw inside a prewarm scope (same pattern as the reader's
|
||||
// renderContents) so SD-card font glyphs load from SD in one batch instead
|
||||
// of one on-demand overflow read per character on every page turn.
|
||||
const int fontId = SETTINGS.getReaderFontId();
|
||||
const int bodyStartY = contentY + metrics.topPadding + metrics.headerHeight;
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY); // scan pass: records codepoints only
|
||||
scope.endScanAndPrewarm();
|
||||
drawBody(fontId, contentX + SIDE_PADDING, bodyStartY);
|
||||
|
||||
const auto labels =
|
||||
mappedInput.mapLabels(tr(STR_BACK), "", (currentPage > 0 ? "<" : ""), (currentPage + 1 < totalPages ? ">" : ""));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// Paged plain-text viewer for one dictionary definition. The definition is
|
||||
// word-wrapped once on entry; each page renders spans of the original string,
|
||||
// so no per-line copies are held.
|
||||
class DictionaryDefinitionActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string headword,
|
||||
std::string definition)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(std::move(headword)),
|
||||
definition(std::move(definition)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// One wrapped display line: a byte span of `definition`. Wrapping keeps
|
||||
// lines under the screen width, so uint16_t length is ample.
|
||||
struct Line {
|
||||
uint32_t start;
|
||||
uint16_t len;
|
||||
};
|
||||
|
||||
void wrapText();
|
||||
int measureSpan(int fontId, const char* text, size_t len) const;
|
||||
void drawBody(int fontId, int x, int startY) const;
|
||||
|
||||
const std::string headword;
|
||||
// Not const: onEnter() normalizes embedded NULs (StarDict multi-type
|
||||
// separators) to newlines so C-string APIs see the whole text.
|
||||
std::string definition;
|
||||
std::vector<Line> lines;
|
||||
int currentPage = 0;
|
||||
int totalPages = 1;
|
||||
int linesPerPage = 1;
|
||||
ButtonNavigator buttonNavigator;
|
||||
};
|
||||
@@ -0,0 +1,337 @@
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
|
||||
#include <FontCacheManager.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Memory.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <climits>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr unsigned long POPUP_DURATION_MS = 1500;
|
||||
|
||||
// A token is selectable when it has an ASCII alphanumeric or a non-ASCII
|
||||
// codepoint outside U+2000-U+206F (dashes, bullets and other General
|
||||
// Punctuation that appear as standalone tokens are not words).
|
||||
bool isSelectableToken(const char* text) {
|
||||
for (const uint8_t* p = reinterpret_cast<const uint8_t*>(text); *p != 0; p++) {
|
||||
if (*p < 0x80) {
|
||||
if (std::isalnum(*p)) return true;
|
||||
} else if (*p == 0xE2 && (p[1] == 0x80 || p[1] == 0x81)) {
|
||||
if (p[2] == 0) break; // truncated sequence: skipping would step past the NUL
|
||||
p += 2; // skip the 3-byte General Punctuation codepoint
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void indexBuildYield(void*) { vTaskDelay(1); }
|
||||
|
||||
} // namespace
|
||||
|
||||
void DictionaryWordSelectActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
fontId = SETTINGS.getReaderFontId();
|
||||
lineHeight = renderer.getLineHeight(fontId);
|
||||
// No null check: a failed allocation just disables the differential
|
||||
// fast path (drawHighlightWithSnapshot skips the read), keeping the
|
||||
// full-repaint path as the fallback.
|
||||
snapshot = makeUniqueNoThrow<uint8_t[]>(SNAPSHOT_CAPACITY);
|
||||
extractWords();
|
||||
// Start on the middle row's word nearest mid-screen instead of top-left:
|
||||
// any word on the page is then at most half a page of moves away.
|
||||
if (!words.empty()) {
|
||||
const int initial = closestInRow(rowCount / 2, renderer.getScreenWidth() / 2);
|
||||
if (initial >= 0) selected = initial;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::extractWords() {
|
||||
words.clear();
|
||||
words.reserve(128);
|
||||
rowCount = 0;
|
||||
|
||||
// Single walk: collect the selectable words while accumulating their text
|
||||
// and styles (~2KB transient string, freed on return). Widths are measured
|
||||
// afterwards: merging the page's codepoints into the SD font's persistent
|
||||
// advance table first keeps getTextAdvanceX on the in-RAM path instead of
|
||||
// loading glyphs from SD one overflow slot at a time.
|
||||
std::string pageText;
|
||||
pageText.reserve(2048);
|
||||
uint8_t styleMask = 0;
|
||||
|
||||
for (const auto& element : page->elements) {
|
||||
if (element->getTag() != TAG_PageLine) continue;
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
const auto& block = line->getBlock();
|
||||
if (!block || !block->valid()) continue;
|
||||
|
||||
bool rowHasWords = false;
|
||||
for (uint16_t i = 0; i < block->wordCount(); i++) {
|
||||
const char* text = block->wordText(i);
|
||||
if (!isSelectableToken(text)) continue;
|
||||
|
||||
WordBox box;
|
||||
box.x = static_cast<int16_t>(line->xPos + block->wordXpos(i) + marginLeft);
|
||||
box.y = static_cast<int16_t>(line->yPos + marginTop);
|
||||
box.style = block->wordStyle(i);
|
||||
box.width = 0; // measured below, once the advance table is ready
|
||||
box.row = rowCount;
|
||||
box.text = text;
|
||||
words.push_back(box);
|
||||
rowHasWords = true;
|
||||
|
||||
pageText.append(text);
|
||||
pageText.push_back(' ');
|
||||
styleMask |= static_cast<uint8_t>(1u << (static_cast<uint8_t>(box.style) & 0x03));
|
||||
}
|
||||
if (rowHasWords) rowCount++;
|
||||
}
|
||||
|
||||
if (styleMask == 0) styleMask = 0x01; // REGULAR
|
||||
renderer.ensureSdCardFontReady(fontId, pageText.c_str(), styleMask);
|
||||
for (auto& word : words) {
|
||||
word.width = static_cast<int16_t>(renderer.getTextAdvanceX(fontId, word.text, word.style));
|
||||
}
|
||||
}
|
||||
|
||||
// Index of the word whose box (with finger-sized slop) contains the touch
|
||||
// point; -1 when the touch lands on no word. Boxes never overlap after the
|
||||
// slop grows them, at worst they touch, so first hit wins.
|
||||
int DictionaryWordSelectActivity::wordAt(const int x, const int y) const {
|
||||
constexpr int SLOP = 4; // matches the highlight box (+2) plus finger error
|
||||
for (int i = 0; i < static_cast<int>(words.size()); i++) {
|
||||
const WordBox& word = words[i];
|
||||
if (x >= word.x - SLOP && x < word.x + word.width + SLOP && y >= word.y - SLOP && y < word.y + lineHeight + SLOP) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Index of the word in `row` whose horizontal center is closest to centerX;
|
||||
// -1 when the row has no words.
|
||||
int DictionaryWordSelectActivity::closestInRow(const uint16_t row, const int centerX) const {
|
||||
int best = -1;
|
||||
int bestDistance = INT_MAX;
|
||||
for (int i = 0; i < static_cast<int>(words.size()); i++) {
|
||||
if (words[i].row != row) continue;
|
||||
const int distance = std::abs(words[i].x + words[i].width / 2 - centerX);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::moveVertical(const int direction) {
|
||||
const WordBox& current = words[selected];
|
||||
const int targetRow = static_cast<int>(current.row) + direction;
|
||||
if (targetRow < 0 || targetRow >= static_cast<int>(rowCount)) return;
|
||||
|
||||
const int best = closestInRow(static_cast<uint16_t>(targetRow), current.x + current.width / 2);
|
||||
if (best >= 0 && best != selected) {
|
||||
selected = best;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::performLookup() {
|
||||
popup = Popup::Busy;
|
||||
if (!dictOpenAttempted) {
|
||||
dictOpenAttempted = true;
|
||||
dictOpenOk = dict.open(SETTINGS.dictionaryName);
|
||||
}
|
||||
const bool indexing = dictOpenOk && dict.needsIndex();
|
||||
popupMsg = indexing ? StrId::STR_DICT_INDEXING : StrId::STR_DICT_LOOKING_UP;
|
||||
requestUpdateAndWait(); // paint the page + busy popup before blocking on SD
|
||||
|
||||
bool ok = dictOpenOk;
|
||||
if (ok && indexing) ok = dict.buildIndex(&indexBuildYield);
|
||||
|
||||
std::string definition;
|
||||
std::string headword;
|
||||
const bool found = ok && dict.lookup(words[selected].text, definition, headword);
|
||||
|
||||
if (found) {
|
||||
popup = Popup::None;
|
||||
startActivityForResult(std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, std::move(headword),
|
||||
std::move(definition)),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
return;
|
||||
}
|
||||
popup = ok ? Popup::NotFound : Popup::Error;
|
||||
popupMsg = ok ? StrId::STR_DICT_NOT_FOUND : StrId::STR_DICT_ERROR;
|
||||
popupTime = millis();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::loop() {
|
||||
if (popup == Popup::NotFound || popup == Popup::Error) {
|
||||
if (millis() - popupTime >= POPUP_DURATION_MS) {
|
||||
popup = Popup::None;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) confirmPressSeen = true;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && confirmPressSeen && !words.empty()) {
|
||||
performLookup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (words.empty()) return;
|
||||
|
||||
// Touch: a touch-down moves the highlight to the touched word (differential
|
||||
// repaint), a tap on a word selects and looks it up in one go.
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasScreenTouchDown(tx, ty)) {
|
||||
const int hit = wordAt(tx, ty);
|
||||
if (hit >= 0 && hit != selected) {
|
||||
selected = hit;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasScreenTapped(tx, ty)) {
|
||||
const int hit = wordAt(tx, ty);
|
||||
if (hit >= 0) {
|
||||
selected = hit;
|
||||
performLookup();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left) && selected > 0) {
|
||||
selected--;
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Right) &&
|
||||
selected + 1 < static_cast<int>(words.size())) {
|
||||
selected++;
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
moveVertical(-1);
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
moveVertical(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Saves the pixels under words[selected]'s highlight box, then draws the
|
||||
// highlight over them. Returns false when the pixels could not be saved
|
||||
// (no buffer / oversize box) — the highlight is drawn regardless, but the
|
||||
// next cursor move must do a full repaint.
|
||||
bool DictionaryWordSelectActivity::drawHighlightWithSnapshot() {
|
||||
const WordBox& word = words[selected];
|
||||
int hx = word.x - 2;
|
||||
int hy = word.y - 2;
|
||||
int hw = word.width + 4;
|
||||
int hh = lineHeight + 4;
|
||||
// Clamp to the panel so save, draw and restore all use the same box.
|
||||
if (hx < 0) {
|
||||
hw += hx;
|
||||
hx = 0;
|
||||
}
|
||||
if (hy < 0) {
|
||||
hh += hy;
|
||||
hy = 0;
|
||||
}
|
||||
|
||||
bool saved = false;
|
||||
if (snapshot && hw > 0 && hh > 0) {
|
||||
saved = renderer.readFramebufferRegion(hx, hy, hw, hh, snapshot.get(), SNAPSHOT_CAPACITY) > 0;
|
||||
}
|
||||
snapshotX = static_cast<int16_t>(hx);
|
||||
snapshotY = static_cast<int16_t>(hy);
|
||||
snapshotW = static_cast<int16_t>(hw);
|
||||
snapshotH = static_cast<int16_t>(hh);
|
||||
snapshotIdx = saved ? selected : -1;
|
||||
|
||||
renderer.fillRect(hx, hy, hw, hh, true);
|
||||
renderer.drawText(fontId, word.x, word.y, word.text, false, word.style);
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Front-button bar (Back/Confirm/Left/Right). Drawn last on every repaint
|
||||
// path, including the differential highlight-only path, so it always ends
|
||||
// up as the top layer even when a highlighted word's box falls under a
|
||||
// hint's screen area. No side-button hints: Up/Down row jump has no spare
|
||||
// screen area on this page (it reuses the reader's full-bleed layout), and
|
||||
// a hint box there would hide text instead of sitting in a reserved gutter.
|
||||
void DictionaryWordSelectActivity::drawHints() const {
|
||||
// No selectable word on this page: Confirm/Left/Right are all no-ops
|
||||
// (guarded by words.empty() in loop()/performLookup), so only Back does
|
||||
// anything and only Back is hinted.
|
||||
if (words.empty()) {
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
return;
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_LOOKUP), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::render(RenderLock&&) {
|
||||
// Differential fast path: only the highlight moved and the framebuffer
|
||||
// still holds a clean page (no popup or sub-activity since the last full
|
||||
// repaint). Restore the pixels under the old highlight, draw the new one,
|
||||
// and push — skipping the two-pass page render entirely.
|
||||
if (popup == Popup::None && snapshotIdx >= 0 && !words.empty() && selected != snapshotIdx) {
|
||||
renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get());
|
||||
// The full path's PrewarmScope cleared the glyph cache on exit; batch-load
|
||||
// just the highlighted word's glyphs before drawing them white-on-black.
|
||||
renderer.getFontCacheManager()->prewarmCache(
|
||||
fontId, words[selected].text, static_cast<uint8_t>(1u << (static_cast<uint8_t>(words[selected].style) & 0x03)));
|
||||
if (drawHighlightWithSnapshot()) {
|
||||
drawHints();
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
return;
|
||||
}
|
||||
// Snapshot failed (oversize box) — fall through to a full repaint.
|
||||
}
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
// Same prewarm-scan-then-render pass the reader uses, so SD-card fonts hit
|
||||
// the in-RAM glyph cache during the real draw.
|
||||
auto* fcm = renderer.getFontCacheManager();
|
||||
auto scope = fcm->createPrewarmScope();
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
scope.endScanAndPrewarm();
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
|
||||
if (!words.empty()) {
|
||||
drawHighlightWithSnapshot();
|
||||
}
|
||||
|
||||
drawHints();
|
||||
|
||||
if (popup != Popup::None) {
|
||||
// The popup overdraws the page, so the snapshot no longer matches the
|
||||
// framebuffer — force the next render onto the full-repaint path.
|
||||
snapshotIdx = -1;
|
||||
// drawPopup overlays the framebuffer and refreshes the display itself.
|
||||
// I18N.get directly: tr() only accepts literal key names.
|
||||
GUI.drawPopup(renderer, I18N.get(popupMsg));
|
||||
return;
|
||||
}
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
#pragma once
|
||||
|
||||
#include <Epub/Page.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
// Word selection over the current reader page: Left/Right step through words
|
||||
// in reading order, Up/Down jump rows, Confirm looks the word up and opens
|
||||
// DictionaryDefinitionActivity, Back returns to the reader. On touch devices a
|
||||
// touch-down moves the highlight and a tap on a word looks it up directly.
|
||||
class DictionaryWordSelectActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::unique_ptr<Page> page, int marginLeft, int marginTop)
|
||||
: Activity("DictionaryWordSelect", renderer, mappedInput),
|
||||
page(std::move(page)),
|
||||
marginLeft(marginLeft),
|
||||
marginTop(marginTop) {}
|
||||
|
||||
void onEnter() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// Screen box of one selectable word. `text` points into the owned Page's
|
||||
// TextBlock arena (NUL-terminated), valid for this activity's lifetime.
|
||||
struct WordBox {
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
int16_t width;
|
||||
uint16_t row;
|
||||
const char* text;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
enum class Popup : uint8_t { None, Busy, NotFound, Error };
|
||||
|
||||
void extractWords();
|
||||
int closestInRow(uint16_t row, int centerX) const;
|
||||
int wordAt(int x, int y) const;
|
||||
void moveVertical(int direction);
|
||||
void performLookup();
|
||||
bool drawHighlightWithSnapshot();
|
||||
void drawHints() const;
|
||||
|
||||
std::unique_ptr<Page> page;
|
||||
const int marginLeft;
|
||||
const int marginTop;
|
||||
int fontId = 0;
|
||||
int lineHeight = 0;
|
||||
|
||||
std::vector<WordBox> words;
|
||||
int selected = 0;
|
||||
uint16_t rowCount = 0;
|
||||
|
||||
Dictionary dict;
|
||||
bool dictOpenAttempted = false;
|
||||
bool dictOpenOk = false;
|
||||
|
||||
Popup popup = Popup::None;
|
||||
StrId popupMsg = StrId::STR_DICT_NOT_FOUND;
|
||||
unsigned long popupTime = 0;
|
||||
|
||||
// Differential highlight repaint: the pixels under the current highlight
|
||||
// box, so a cursor move restores them and repaints only the two affected
|
||||
// boxes instead of re-running the full two-pass page render (which also
|
||||
// reloads every SD-font glyph on the page). snapshotIdx is the word whose
|
||||
// under-pixels are saved; -1 means the framebuffer no longer holds a clean
|
||||
// page (popup drawn, sub-activity shown) and the next render must be full.
|
||||
static constexpr size_t SNAPSHOT_CAPACITY = 4096;
|
||||
std::unique_ptr<uint8_t[]> snapshot;
|
||||
int16_t snapshotX = 0;
|
||||
int16_t snapshotY = 0;
|
||||
int16_t snapshotW = 0;
|
||||
int16_t snapshotH = 0;
|
||||
int snapshotIdx = -1;
|
||||
|
||||
// The activity is entered while Confirm is still held (long-press trigger):
|
||||
// ignore the stale release until a fresh press is seen.
|
||||
bool confirmPressSeen = false;
|
||||
};
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "ReaderUtils.h"
|
||||
// ReaderUtils.h pulls in ActivityManager.h, which only forward-declares Activity while holding
|
||||
// std::unique_ptr<Activity> members. Destroying that unique_ptr needs the complete type, so the
|
||||
// definition must be visible here.
|
||||
#include "activities/Activity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#include "BookmarkEntry.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderBookmarksActivity.h"
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "EpubReaderFootnotesActivity.h"
|
||||
@@ -269,6 +270,29 @@ bool EpubReaderActivity::buildTickHeapGate() {
|
||||
return !buildHeapPaused;
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openDictionaryWordSelect() {
|
||||
if (SETTINGS.dictionaryName[0] == '\0') {
|
||||
showDictionaryMessage = true;
|
||||
dictionaryMessageTime = millis();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (!section) return;
|
||||
auto page = section->loadPage(section->currentPage);
|
||||
if (!page) return;
|
||||
|
||||
// Word geometry must match render(): viewable-area margins plus screen margin.
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
&orientedMarginLeft);
|
||||
orientedMarginTop += SETTINGS.screenMargin;
|
||||
orientedMarginLeft += SETTINGS.screenMargin;
|
||||
|
||||
startActivityForResult(std::make_unique<DictionaryWordSelectActivity>(renderer, mappedInput, std::move(page),
|
||||
orientedMarginLeft, orientedMarginTop),
|
||||
[this](const ActivityResult&) { requestUpdate(); });
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loop() {
|
||||
if (!epub) {
|
||||
// Should never happen
|
||||
@@ -397,9 +421,11 @@ void EpubReaderActivity::loop() {
|
||||
pendingReadFolderMove = false;
|
||||
}
|
||||
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
|
||||
|
||||
if (automaticPageTurnActive) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Back) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
|
||||
automaticPageTurnActive = false;
|
||||
// updates chapter title space to indicate page turn disabled
|
||||
requestUpdate();
|
||||
@@ -428,6 +454,11 @@ void EpubReaderActivity::loop() {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (showDictionaryMessage && (millis() - dictionaryMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) {
|
||||
showDictionaryMessage = false;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
// input. Anything it doesn't handle (e.g. long-press Back to the file browser) falls
|
||||
// through to the regular handlers below; page turns are absorbed by the end-of-book
|
||||
@@ -457,10 +488,10 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enter reader menu activity on short-press Confirm. A long-press that fired a bound
|
||||
// function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
||||
// Enter reader menu activity on short-press Confirm or a downward swipe from the top edge. A long-press
|
||||
// that fired a bound function (bookmark or KOReader sync) sets ignoreNextConfirmRelease so the release
|
||||
// following the hold does not also open the menu.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
@@ -491,26 +522,29 @@ void EpubReaderActivity::loop() {
|
||||
}
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_DICTIONARY:
|
||||
// Hold ~0.4s starts dictionary word selection on the current page.
|
||||
if (mappedInput.getHeldTime() >= ReaderUtils::BOOKMARK_HOLD_MS && !showDictionaryMessage) {
|
||||
ignoreNextConfirmRelease = true; // Prevent menu open on the release that follows
|
||||
openDictionaryWordSelect();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case CrossPointSettings::LP_MENU_DISABLED:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
|
||||
// Short press Back restores position when viewing a footnote (takes priority over navigation)
|
||||
if (footnoteDepth > 0 && mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_BACK_OR_HOME_MS) {
|
||||
restoreSavedPosition();
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes directly to home (or restores position if viewing footnote)
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
if (footnoteDepth > 0) {
|
||||
restoreSavedPosition();
|
||||
return;
|
||||
}
|
||||
onGoHome();
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, epub ? epub->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<EpubReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -540,7 +574,9 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -564,7 +600,8 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool longPress = !fromTilt && mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
|
||||
const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS;
|
||||
|
||||
// Don't skip chapter after screenshot
|
||||
if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) {
|
||||
@@ -764,6 +801,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
});
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DICTIONARY: {
|
||||
openDictionaryWordSelect();
|
||||
break;
|
||||
}
|
||||
case EpubReaderMenuActivity::MenuAction::DISPLAY_QR: {
|
||||
if (section && section->currentPage >= 0 && section->currentPage < section->pageCount) {
|
||||
std::string fullText = section->getTextFromSectionFile();
|
||||
@@ -1355,6 +1396,10 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
if (showBookmarkMessage) {
|
||||
GUI.drawPopup(renderer, bookmarkRemoved ? tr(STR_BOOKMARK_REMOVED) : tr(STR_BOOKMARK_ADDED));
|
||||
}
|
||||
|
||||
if (showDictionaryMessage) {
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_NO_DICT_SET));
|
||||
}
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::applyDeferredReposition() {
|
||||
|
||||
@@ -39,6 +39,9 @@ class EpubReaderActivity final : public Activity {
|
||||
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
|
||||
bool automaticPageTurnActive = false;
|
||||
bool showBookmarkMessage = false;
|
||||
// "No dictionary set" popup, shown when a lookup is triggered without a configured dictionary.
|
||||
bool showDictionaryMessage = false;
|
||||
unsigned long dictionaryMessageTime = 0UL;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
bool currentPageBookmarked = false;
|
||||
// Idle-time glyph prewarm: after a page settles, scan the LIKELY next page
|
||||
@@ -153,6 +156,7 @@ class EpubReaderActivity final : public Activity {
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
void openDictionaryWordSelect();
|
||||
// Returns true if sync acted (launched, or surfaced a save error); false if it was a no-op
|
||||
// because no KOReader credentials are stored.
|
||||
bool launchKOReaderSync();
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
|
||||
namespace {
|
||||
constexpr int ENTER_DELETE_MODE_MS = 700;
|
||||
constexpr int DELETE_MODE_OFF = 0;
|
||||
constexpr int DELETE_MODE_DISPLAY = 1;
|
||||
constexpr int DELETE_MODE_CONFIRM = 2;
|
||||
|
||||
// Layout constants used in renderScreen
|
||||
constexpr int LINE_HEIGHT = 60;
|
||||
@@ -64,45 +61,7 @@ int EpubReaderBookmarksActivity::getListHeight(const GfxRenderer& renderer) {
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::loop() {
|
||||
// Delete confirmation mode
|
||||
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (confirmingDelete == DELETE_MODE_DISPLAY) {
|
||||
confirmingDelete = DELETE_MODE_CONFIRM; // first confirmation, update text
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
bookmarks.erase(bookmarks.begin() + selectorIndex);
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
||||
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
|
||||
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to save bookmarks after delete");
|
||||
}
|
||||
|
||||
// Move selector up if we deleted the last item
|
||||
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
|
||||
selectorIndex--;
|
||||
}
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
confirmingDelete = DELETE_MODE_OFF;
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
requestUpdate();
|
||||
confirmingDelete = DELETE_MODE_OFF;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
auto openBookmark = [this] {
|
||||
if (bookmarks.empty()) {
|
||||
return;
|
||||
}
|
||||
@@ -119,8 +78,18 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
}
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
};
|
||||
|
||||
// Delete confirmation popup
|
||||
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
if (confirmingDelete) {
|
||||
// Popup dismissed without a selection (Back button or tap outside): cancel delete
|
||||
confirmingDelete = false;
|
||||
requestUpdate();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
@@ -128,11 +97,68 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 40 : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
const int listY = contentY + LINE_HEIGHT;
|
||||
const int listHeight = getListHeight(renderer);
|
||||
int tapped = 0;
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasScreenTouchDown(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
|
||||
mappedInput.wasListItemTouchedDown(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
|
||||
true)) {
|
||||
if (selectorIndex != tapped) {
|
||||
selectorIndex = tapped;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasScreenTapped(tx, ty) && tx >= contentX && tx < contentX + contentWidth &&
|
||||
mappedInput.wasListItemTapped(tapped, static_cast<int>(bookmarks.size()), selectorIndex, listY, listHeight,
|
||||
true)) {
|
||||
selectorIndex = tapped;
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up && !bookmarks.empty()) {
|
||||
selectorIndex =
|
||||
ButtonNavigator::nextPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down && !bookmarks.empty()) {
|
||||
selectorIndex =
|
||||
ButtonNavigator::previousPageIndex(selectorIndex, bookmarks.size(), GUI.getListPageItems(listHeight, true));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Open
|
||||
openBookmark();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > ENTER_DELETE_MODE_MS) {
|
||||
if (bookmarks.empty()) {
|
||||
return;
|
||||
}
|
||||
confirmingDelete = DELETE_MODE_DISPLAY;
|
||||
confirmingDelete = true;
|
||||
const char* options[] = {tr(STR_CANCEL), tr(STR_DELETE)};
|
||||
confirmPopup.show(tr(STR_CONFIRM_DELETE_BOOKMARK), options, 2, 0, [this](int idx) {
|
||||
confirmingDelete = false;
|
||||
if (idx == 1) {
|
||||
deleteSelectedBookmark();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -159,6 +185,27 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::deleteSelectedBookmark() {
|
||||
bookmarks.erase(bookmarks.begin() + selectorIndex);
|
||||
const std::string path = BookmarkUtil::getBookmarkPath(epubPath);
|
||||
Storage.mkdir(BookmarkUtil::getBookmarksDir().c_str());
|
||||
if (!JsonSettingsIO::saveBookmarks(bookmarks, path.c_str())) {
|
||||
LOG_ERR("EPB", "Failed to save bookmarks after delete");
|
||||
}
|
||||
|
||||
// Move selector up if we deleted the last item
|
||||
if (selectorIndex >= bookmarks.size() && selectorIndex > 0) {
|
||||
selectorIndex--;
|
||||
}
|
||||
|
||||
if (bookmarks.empty()) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
@@ -188,10 +235,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, tr(STR_BOOKMARKS), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto getBookmarkTitle = [this](int index) {
|
||||
return bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index).summary;
|
||||
return bookmarks.at(confirmingDelete ? selectorIndex : index).summary;
|
||||
};
|
||||
const auto getBookmarkSubtitle = [this](int index) {
|
||||
auto bookmark = bookmarks.at(confirmingDelete >= DELETE_MODE_DISPLAY ? selectorIndex : index);
|
||||
auto bookmark = bookmarks.at(confirmingDelete ? selectorIndex : index);
|
||||
auto tocIndex = epub->getTocIndexForSpineIndex(bookmark.computedSpineIndex);
|
||||
auto tocTitle = (tocIndex >= 0) ? (epub->getTocItem(tocIndex)).title : tr(STR_UNNAMED);
|
||||
std::string subtitle = std::to_string((int)(std::clamp(bookmark.percentage, 0.0f, 1.0f) * 100.0f + 0.5f)) + "% - ";
|
||||
@@ -207,12 +254,9 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
};
|
||||
|
||||
if (numBookmarks > 0) {
|
||||
if (confirmingDelete >= DELETE_MODE_DISPLAY) {
|
||||
GUI.drawHelpText(renderer, Rect{0, pageHeight / 2 - LINE_HEIGHT * 2, contentWidth, LINE_HEIGHT},
|
||||
tr(STR_CONFIRM_DELETE_BOOKMARK));
|
||||
|
||||
// render list with just the selected item for the user to confirm to delete
|
||||
GUI.drawList(renderer, Rect{contentX, pageHeight / 2, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
|
||||
if (confirmingDelete) {
|
||||
// Render just the selected item near the top; the confirmation popup occupies the center
|
||||
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, LINE_HEIGHT}, 1, 0, getBookmarkTitle,
|
||||
getBookmarkSubtitle, getBookmarkIcon);
|
||||
} else {
|
||||
GUI.drawList(renderer, Rect{contentX, listY, contentWidth, listHeight}, numBookmarks, selectorIndex,
|
||||
@@ -223,10 +267,10 @@ void EpubReaderBookmarksActivity::render(RenderLock&&) {
|
||||
}
|
||||
}
|
||||
|
||||
const auto backLabel = confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||
const auto confirmLabel =
|
||||
bookmarks.size() > 0 ? (confirmingDelete >= DELETE_MODE_DISPLAY ? tr(STR_DELETE) : tr(STR_SELECT)) : "";
|
||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
if (confirmPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
const auto confirmLabel = bookmarks.size() > 0 ? tr(STR_SELECT) : "";
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "../../BookmarkEntry.h"
|
||||
#include "../Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderBookmarksActivity final : public Activity {
|
||||
@@ -13,7 +14,8 @@ class EpubReaderBookmarksActivity final : public Activity {
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
std::vector<BookmarkEntry> bookmarks;
|
||||
int confirmingDelete = 0; // 0 = hide dialog, 1 = show dialog, 2 = allow confirmation to delete
|
||||
bool confirmingDelete = false;
|
||||
OptionPopup confirmPopup;
|
||||
|
||||
public:
|
||||
explicit EpubReaderBookmarksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
@@ -30,4 +32,7 @@ class EpubReaderBookmarksActivity final : public Activity {
|
||||
|
||||
// Calculate the height available for the bookmark list based on orientation
|
||||
int getListHeight(const GfxRenderer& renderer);
|
||||
|
||||
// Delete the currently selected bookmark and persist the list
|
||||
void deleteSelectedBookmark();
|
||||
};
|
||||
|
||||
@@ -31,7 +31,15 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
auto selectChapter = [this] {
|
||||
const auto tocItem = epub->getTocItem(selectorIndex);
|
||||
if (tocItem.spineIndex == -1) {
|
||||
ActivityResult result;
|
||||
@@ -42,11 +50,36 @@ void EpubReaderChapterSelectionActivity::loop() {
|
||||
setResult(ChapterResult{tocItem.spineIndex, tocItem.anchor});
|
||||
finish();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
};
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
|
||||
switch (handleListTouch(selectorIndex, totalItems, contentTop, contentHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
selectChapter();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
selectChapter();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
|
||||
@@ -18,6 +18,13 @@ void EpubReaderFootnotesActivity::onEnter() {
|
||||
void EpubReaderFootnotesActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderFootnotesActivity::loop() {
|
||||
auto selectFootnote = [this] {
|
||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
|
||||
setResult(FootnoteResult{footnotes[selectedIndex].href});
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
@@ -28,13 +35,53 @@ void EpubReaderFootnotesActivity::loop() {
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Power)) {
|
||||
if (selectedIndex >= 0 && selectedIndex < static_cast<int>(footnotes.size())) {
|
||||
setResult(FootnoteResult{footnotes[selectedIndex].href});
|
||||
finish();
|
||||
}
|
||||
selectFootnote();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!footnotes.empty()) {
|
||||
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 = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
constexpr int lineHeight = 36;
|
||||
const int listTop = 60 + contentY;
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, lineHeight, visibleCount, contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = scrollOffset + row;
|
||||
if (touched >= 0 && touched < static_cast<int>(footnotes.size())) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedIndex != touched) {
|
||||
selectedIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectedIndex = touched;
|
||||
selectFootnote();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex = std::min(static_cast<int>(footnotes.size()) - 1, selectedIndex + visibleCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = std::max(0, selectedIndex - visibleCount);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
if (!footnotes.empty()) {
|
||||
selectedIndex = (selectedIndex + 1) % footnotes.size();
|
||||
@@ -83,13 +130,14 @@ void EpubReaderFootnotesActivity::render(RenderLock&&) {
|
||||
constexpr int lineHeight = 36;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int marginLeft = contentX + 20;
|
||||
const int listTop = 60 + contentY;
|
||||
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - contentY) / lineHeight);
|
||||
const int visibleCount = std::max(1, (renderer.getScreenHeight() - listTop) / lineHeight);
|
||||
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
||||
if (selectedIndex >= scrollOffset + visibleCount) scrollOffset = selectedIndex - visibleCount + 1;
|
||||
|
||||
for (int i = scrollOffset; i < static_cast<int>(footnotes.size()) && i < scrollOffset + visibleCount; i++) {
|
||||
const int y = 60 + contentY + (i - scrollOffset) * lineHeight;
|
||||
const int y = listTop + (i - scrollOffset) * lineHeight;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
if (isSelected) {
|
||||
|
||||
@@ -22,7 +22,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
|
||||
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes,
|
||||
bool hasBookmarks) {
|
||||
std::vector<MenuItem> items;
|
||||
items.reserve(12);
|
||||
items.reserve(13);
|
||||
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
|
||||
if (hasFootnotes) {
|
||||
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
|
||||
@@ -31,6 +31,7 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
|
||||
items.push_back({MenuAction::BOOKMARKS, StrId::STR_BOOKMARKS});
|
||||
}
|
||||
items.push_back({MenuAction::TOGGLE_BOOKMARK, StrId::STR_TOGGLE_BOOKMARK});
|
||||
items.push_back({MenuAction::DICTIONARY, StrId::STR_LOOKUP});
|
||||
items.push_back({MenuAction::ROTATE_SCREEN, StrId::STR_ORIENTATION});
|
||||
items.push_back({MenuAction::AUTO_PAGE_TURN, StrId::STR_AUTO_TURN_PAGES_PER_MIN});
|
||||
items.push_back({MenuAction::GO_TO_PERCENT, StrId::STR_GO_TO_PERCENT});
|
||||
@@ -49,21 +50,45 @@ void EpubReaderMenuActivity::onEnter() {
|
||||
|
||||
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderMenuActivity::closeCancelled() {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption};
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
|
||||
bool EpubReaderMenuActivity::handleHomeGesture() {
|
||||
closeCancelled();
|
||||
return true;
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) {
|
||||
// The popup acts on button press; if that input closed it, the trailing
|
||||
// release must be swallowed below (Back would close the menu, Confirm
|
||||
// would re-activate the selected item).
|
||||
popupClosing = !optionPopup.isActive();
|
||||
return;
|
||||
}
|
||||
if (popupClosing) {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||
return; // closing press still held
|
||||
}
|
||||
popupClosing = false;
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
return; // swallow the release that closed the popup
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
closeCancelled();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast<int>(orientationLabels.size()),
|
||||
@@ -87,13 +112,48 @@ void EpubReaderMenuActivity::loop() {
|
||||
|
||||
setResult(MenuResult{static_cast<int>(selectedAction), pendingOrientation, selectedPageTurnOption});
|
||||
finish();
|
||||
};
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop =
|
||||
screen.y + metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = screen.height - contentTop - metrics.verticalSpacing;
|
||||
switch (handleListTouch(selectedIndex, static_cast<int>(menuItems.size()), contentTop, contentHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
activateSelected();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
result.data = MenuResult{-1, pendingOrientation, selectedPageTurnOption};
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
DISPLAY_QR,
|
||||
GO_HOME,
|
||||
SYNC,
|
||||
DELETE_CACHE
|
||||
DELETE_CACHE,
|
||||
DICTIONARY
|
||||
};
|
||||
|
||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||
@@ -35,6 +36,7 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool handleHomeGesture() override;
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
@@ -43,6 +45,7 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
};
|
||||
|
||||
static std::vector<MenuItem> buildMenuItems(bool hasFootnotes, bool hasBookmarks);
|
||||
void closeCancelled();
|
||||
|
||||
// Fixed menu layout
|
||||
const std::vector<MenuItem> menuItems;
|
||||
@@ -51,6 +54,9 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
OptionPopup optionPopup;
|
||||
// True while the button press that closed the popup is still held; its release
|
||||
// must not fall through to the menu's own Back/Confirm handlers.
|
||||
bool popupClosing = false;
|
||||
std::string title = "Reader Menu";
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t selectedPageTurnOption = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <HalGPIO.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
@@ -36,6 +37,38 @@ void EpubReaderPercentSelectionActivity::adjustPercent(const int delta) {
|
||||
}
|
||||
|
||||
void EpubReaderPercentSelectionActivity::loop() {
|
||||
auto& theme = UITheme::getInstance();
|
||||
auto metrics = theme.getMetrics();
|
||||
Rect screen = theme.getScreenSafeArea(renderer, true, false);
|
||||
const int contentTop = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 4;
|
||||
constexpr int barWidth = 360;
|
||||
constexpr int barHeight = 16;
|
||||
const int barX = screen.x + (screen.width - barWidth) / 2;
|
||||
const int barY = contentTop + metrics.verticalSpacing * 2;
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
|
||||
// Live drag on the slider: once a touch lands on the bar, the percent follows the
|
||||
// finger until release. Runs before the Back handler because the release of a drag
|
||||
// can also register as a swipe (e.g. the left-edge rightward back gesture) — the
|
||||
// drag must consume it so it can't cancel the dialog or step the percent.
|
||||
if (mappedInput.isScreenTouchHeld(tx, ty)) {
|
||||
if (draggingBar ||
|
||||
(tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 && ty < barY + barHeight + 24)) {
|
||||
draggingBar = true;
|
||||
const int dragged = std::clamp((tx - barX) * 100 / barWidth, 0, 100);
|
||||
if (dragged != percent) {
|
||||
percent = dragged;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (draggingBar) {
|
||||
// Release frame of a drag: swallow the tap/swipe events it produced.
|
||||
draggingBar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Back cancels, confirm selects, arrows adjust the percent.
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
@@ -45,6 +78,23 @@ void EpubReaderPercentSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasScreenTapped(tx, ty) && tx >= barX - 20 && tx < barX + barWidth + 20 && ty >= barY - 24 &&
|
||||
ty < barY + barHeight + 24) {
|
||||
percent = std::clamp((tx - barX) * 100 / barWidth, 0, 100);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Right) {
|
||||
adjustPercent(kLargeStep);
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Left) {
|
||||
adjustPercent(-kLargeStep);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
setResult(PercentResult{percent});
|
||||
finish();
|
||||
|
||||
@@ -20,6 +20,9 @@ class EpubReaderPercentSelectionActivity final : public Activity {
|
||||
// Current percent value (0-100) shown on the slider.
|
||||
int percent = 0;
|
||||
|
||||
// True while a touch that landed on the slider bar is being dragged.
|
||||
bool draggingBar = false;
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
// Change the current percent by a delta and clamp within bounds.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
#include "Epub/Section.h"
|
||||
#include "EpubReaderUtils.h"
|
||||
@@ -24,6 +25,19 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) {
|
||||
return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path)
|
||||
: KOReaderDocumentId::calculate(path);
|
||||
}
|
||||
|
||||
DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) {
|
||||
return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
|
||||
}
|
||||
|
||||
const char* matchMethodName(const DocumentMatchMethod method) {
|
||||
return method == DocumentMatchMethod::FILENAME ? "filename" : "binary";
|
||||
}
|
||||
|
||||
void syncTimeWithNTP() {
|
||||
// Stop SNTP if already running (can't reconfigure while running)
|
||||
if (esp_sntp_enabled()) {
|
||||
@@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
|
||||
|
||||
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
|
||||
|
||||
bool KOReaderSyncActivity::smartSyncEnabled() const {
|
||||
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART;
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; }
|
||||
|
||||
void KOReaderSyncActivity::completeAlreadySynced() {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNC_COMPLETE;
|
||||
}
|
||||
markAutoReturn();
|
||||
requestUpdate(true);
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
LOG_DBG("KOSync", "WiFi connection failed, exiting");
|
||||
@@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::performSync() {
|
||||
// Calculate document hash based on user's preferred method
|
||||
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
||||
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
||||
} else {
|
||||
documentHash = KOReaderDocumentId::calculate(epubPath);
|
||||
}
|
||||
const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
|
||||
documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
|
||||
if (documentHash.empty()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() {
|
||||
requestUpdate(true);
|
||||
return;
|
||||
}
|
||||
const std::string primaryHash = documentHash;
|
||||
|
||||
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
|
||||
LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() {
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
// Fetch remote progress
|
||||
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
||||
// Fetch remote progress. In smart mode, also probe the alternate document-id
|
||||
// method and use the furthest remote state we can find. This avoids a stale
|
||||
// local upload when another KOReader device synced the same book with a
|
||||
// different document matching method.
|
||||
auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
||||
LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
|
||||
matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(),
|
||||
localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str());
|
||||
|
||||
if (smartSyncEnabled()) {
|
||||
const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod);
|
||||
const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod);
|
||||
if (!altHash.empty() && altHash != documentHash) {
|
||||
KOReaderProgress altProgress;
|
||||
const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress);
|
||||
LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
|
||||
matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(),
|
||||
localProgress.percentage, altProgress.percentage, altProgress.progress.c_str());
|
||||
|
||||
if (altResult == KOReaderSyncClient::OK &&
|
||||
(result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) {
|
||||
documentHash = altHash;
|
||||
remoteProgress = std::move(altProgress);
|
||||
result = KOReaderSyncClient::OK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == KOReaderSyncClient::NOT_FOUND) {
|
||||
if (smartSyncEnabled()) {
|
||||
LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f",
|
||||
localProgress.percentage);
|
||||
performUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
// No remote progress - offer to upload
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
@@ -174,8 +232,42 @@ void KOReaderSyncActivity::performSync() {
|
||||
return;
|
||||
}
|
||||
|
||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||
// Prefer the exact spine/page from a crosspoint-sync rich position (lossless
|
||||
// CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping
|
||||
// for plain kosync servers or when the rich position cannot be applied.
|
||||
std::optional<CrossPointPosition> richMapped;
|
||||
if (remoteProgress.position.has_value()) {
|
||||
richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer);
|
||||
}
|
||||
if (richMapped.has_value()) {
|
||||
remotePosition = *richMapped;
|
||||
} else {
|
||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||
}
|
||||
|
||||
if (smartSyncEnabled()) {
|
||||
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
|
||||
const float delta = localProgress.percentage - remoteProgress.percentage;
|
||||
LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d",
|
||||
documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta,
|
||||
remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber);
|
||||
if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) {
|
||||
completeAlreadySynced();
|
||||
return;
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
// Alternate hashes are only probes for newer remote state. Keep uploads
|
||||
// on the user's configured matching method so its primary record heals.
|
||||
documentHash = primaryHash;
|
||||
performUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
|
||||
{
|
||||
@@ -206,17 +298,45 @@ void KOReaderSyncActivity::performUpload() {
|
||||
progress.progress = localProgress.xpath;
|
||||
progress.percentage = localProgress.percentage;
|
||||
|
||||
// Rich CrossPoint position for crosspoint-sync servers (lossless
|
||||
// CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field.
|
||||
{
|
||||
KOReaderRichPosition pos;
|
||||
const float pct = localProgress.percentage < 0.0f ? 0.0f
|
||||
: localProgress.percentage > 1.0f ? 1.0f
|
||||
: localProgress.percentage;
|
||||
pos.pctQ = static_cast<uint32_t>(pct * 1000000.0f + 0.5f);
|
||||
pos.spineIndex = static_cast<uint16_t>(currentSpineIndex);
|
||||
pos.pageNumber = static_cast<uint16_t>(currentPage);
|
||||
pos.totalPages = static_cast<uint16_t>(totalPagesInSpine > 0 ? totalPagesInSpine : 1);
|
||||
pos.paragraphIndex = currentParagraphIndex;
|
||||
pos.xpath = localProgress.xpath;
|
||||
progress.position = std::move(pos);
|
||||
}
|
||||
|
||||
// Optionally include document metadata (KOReader PR #15306)
|
||||
if (KOREADER_STORE.getSendMetadata()) {
|
||||
// The Epub is released before the sync network calls and is only reloaded on the
|
||||
// remote-progress path (performSync). When uploading from NO_REMOTE_PROGRESS the
|
||||
// Epub is still null, so reload it here and guard the title/author reads to avoid
|
||||
// dereferencing a null Epub. Filename is derived from the path and is always safe.
|
||||
ensureEpubLoaded();
|
||||
KOReaderMetadata meta;
|
||||
// Extract filename from path
|
||||
const auto lastSlash = epubPath.rfind('/');
|
||||
meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath;
|
||||
meta.title = epub->getTitle();
|
||||
meta.authors = epub->getAuthor();
|
||||
if (epub) {
|
||||
meta.title = epub->getTitle();
|
||||
meta.authors = epub->getAuthor();
|
||||
} else {
|
||||
LOG_ERR("KOSync", "Epub unavailable for metadata; sending filename only");
|
||||
}
|
||||
progress.metadata = std::move(meta);
|
||||
}
|
||||
|
||||
// Release the Epub before the network call so the TLS handshake has enough free heap
|
||||
// (consistent with the release-before-sync pattern in performSync); nothing below needs it.
|
||||
epub.reset();
|
||||
|
||||
const auto result = KOReaderSyncClient::updateProgress(progress);
|
||||
|
||||
// Drop the radio while user reads the result; full teardown happens at silent reboot.
|
||||
@@ -236,6 +356,7 @@ void KOReaderSyncActivity::performUpload() {
|
||||
RenderLock lock(*this);
|
||||
state = UPLOAD_COMPLETE;
|
||||
}
|
||||
markAutoReturn();
|
||||
requestUpdate(true);
|
||||
}
|
||||
|
||||
@@ -379,10 +500,12 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == UPLOAD_COMPLETE) {
|
||||
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
|
||||
if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
|
||||
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
|
||||
EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
@@ -400,14 +523,48 @@ void KOReaderSyncActivity::render(RenderLock&&) {
|
||||
}
|
||||
|
||||
void KOReaderSyncActivity::loop() {
|
||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
|
||||
if (autoReturnAt != 0 && millis() >= autoReturnAt) {
|
||||
returnToReader();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
returnToReader();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == SHOWING_RESULT) {
|
||||
auto chooseSelected = [this] {
|
||||
if (selectedOption == 0) {
|
||||
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
|
||||
} else if (selectedOption == 1) {
|
||||
performUpload();
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
constexpr int optionHeight = 30;
|
||||
int touchedOption = -1;
|
||||
const auto touch = mappedInput.rowTouch(touchedOption, top + 230 - 2, optionHeight, 2);
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectedOption != touchedOption) {
|
||||
selectedOption = touchedOption;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (touch == MappedInputManager::RowTouch::Tap) {
|
||||
selectedOption = touchedOption;
|
||||
chooseSelected();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate options
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
@@ -420,12 +577,7 @@ void KOReaderSyncActivity::loop() {
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedOption == 0) {
|
||||
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
|
||||
} else if (selectedOption == 1) {
|
||||
// Upload local progress
|
||||
performUpload();
|
||||
}
|
||||
chooseSelected();
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
@@ -435,6 +587,21 @@ void KOReaderSyncActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == NO_REMOTE_PROGRESS) {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
if (mappedInput.wasScreenTapped(tx, ty) && ty > renderer.getScreenHeight() / 3 &&
|
||||
ty < renderer.getScreenHeight() * 2 / 3) {
|
||||
if (documentHash.empty()) {
|
||||
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
||||
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
||||
} else {
|
||||
documentHash = KOReaderDocumentId::calculate(epubPath);
|
||||
}
|
||||
}
|
||||
performUpload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Calculate hash if not done yet
|
||||
if (documentHash.empty()) {
|
||||
|
||||
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
|
||||
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
@@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity {
|
||||
SHOWING_RESULT,
|
||||
UPLOADING,
|
||||
UPLOAD_COMPLETE,
|
||||
SYNC_COMPLETE,
|
||||
NO_REMOTE_PROGRESS,
|
||||
SYNC_FAILED,
|
||||
NO_CREDENTIALS
|
||||
@@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity {
|
||||
// Selection in result screen (0=Apply, 1=Upload)
|
||||
int selectedOption = 0;
|
||||
|
||||
// Timed return for successful smart-sync terminal states.
|
||||
unsigned long autoReturnAt = 0;
|
||||
static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200;
|
||||
|
||||
// Tracks whether this session activated WiFi. Set in onEnter past the credentials
|
||||
// check; checked in onExit to decide whether to silent-reboot. Can't rely on
|
||||
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
|
||||
@@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity {
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void performSync();
|
||||
void performUpload();
|
||||
bool smartSyncEnabled() const;
|
||||
void markAutoReturn();
|
||||
void completeAlreadySynced();
|
||||
void ensureEpubLoaded();
|
||||
void saveProgressAndReturn(int spineIndex, int page);
|
||||
void returnToReader();
|
||||
|
||||
@@ -16,8 +16,10 @@ void QrDisplayActivity::onEnter() {
|
||||
void QrDisplayActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void QrDisplayActivity::loop() {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
#include <CrossPointSettings.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <HalTiltSensor.h>
|
||||
#include <Logging.h>
|
||||
#include <components/bars/tap-zones.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
|
||||
namespace ReaderUtils {
|
||||
|
||||
constexpr unsigned long GO_HOME_MS = 1000;
|
||||
constexpr unsigned long GO_BACK_OR_HOME_MS = GO_HOME_MS;
|
||||
constexpr unsigned long SKIP_HOLD_MS = 700;
|
||||
constexpr unsigned long BOOKMARK_HOLD_MS = 400;
|
||||
constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500;
|
||||
|
||||
enum ReaderTouchAction : freeink::ui::ActionId {
|
||||
READER_TOUCH_PREV = 1,
|
||||
READER_TOUCH_NEXT = 3,
|
||||
};
|
||||
|
||||
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
|
||||
switch (orientation) {
|
||||
case CrossPointSettings::ORIENTATION::PORTRAIT:
|
||||
@@ -59,6 +68,48 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
|
||||
return {prev, next, tiltPrev || tiltNext};
|
||||
}
|
||||
|
||||
struct TouchPageTurn {
|
||||
bool prev;
|
||||
bool next;
|
||||
unsigned long heldMs;
|
||||
};
|
||||
|
||||
inline TouchPageTurn detectTouchPageTurn(GfxRenderer& renderer, const MappedInputManager& input) {
|
||||
TouchPageTurn result{false, false, 0};
|
||||
if (!SETTINGS.touchReaderControls || !input.hasTouch()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!input.wasScreenTapped(x, y)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const int16_t width = static_cast<int16_t>(renderer.getScreenWidth());
|
||||
const int16_t height = static_cast<int16_t>(renderer.getScreenHeight());
|
||||
const int16_t previousZoneWidth = width / 3;
|
||||
const freeink::ui::TapZone zones[] = {
|
||||
{freeink::ui::Rect{0, 0, previousZoneWidth, height}, READER_TOUCH_PREV},
|
||||
{freeink::ui::Rect{previousZoneWidth, 0, static_cast<int16_t>(width - previousZoneWidth), height},
|
||||
READER_TOUCH_NEXT},
|
||||
};
|
||||
|
||||
for (const auto& zone : zones) {
|
||||
if (!zone.enabled || !zone.rect.contains(static_cast<int16_t>(x), static_cast<int16_t>(y))) continue;
|
||||
result.prev = zone.action == READER_TOUCH_PREV;
|
||||
result.next = zone.action == READER_TOUCH_NEXT;
|
||||
break;
|
||||
}
|
||||
result.heldMs = gpio.lastTouchHeldMs();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Reader menu opens on a downward swipe from the top edge (replaces the old center tap-and-hold).
|
||||
inline bool isTouchMenuGesture(const MappedInputManager& input) {
|
||||
return SETTINGS.touchReaderControls && input.hasTouch() && input.wasMenuGesture();
|
||||
}
|
||||
|
||||
// One helper, blocking or deferred: the async form starts the refresh and
|
||||
// returns so the caller can overlap CPU work with the panel's refresh time.
|
||||
// Async callers must not touch the framebuffer until
|
||||
@@ -105,4 +156,37 @@ void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
|
||||
renderer.restoreBwBuffer();
|
||||
}
|
||||
|
||||
struct BackNavCallback {
|
||||
void* ctx;
|
||||
void (*fn)(void*);
|
||||
};
|
||||
|
||||
// Returns true if the back button was consumed (caller should return).
|
||||
// Long press (>= GO_BACK_OR_HOME_MS):
|
||||
// - default: go to file browser
|
||||
// - with backShortToFileBrowser: go home
|
||||
// Short press (< GO_BACK_OR_HOME_MS):
|
||||
// - default: go home
|
||||
// - with backShortToFileBrowser: go to file browser.
|
||||
inline bool handleBackNavigation(const MappedInputManager& mappedInput, ActivityManager& activityManager,
|
||||
const char* filePath, BackNavCallback goHome) {
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_BACK_OR_HOME_MS) {
|
||||
if (SETTINGS.backShortToFileBrowser) {
|
||||
goHome.fn(goHome.ctx);
|
||||
} else {
|
||||
activityManager.goToFileBrowser(filePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < GO_BACK_OR_HOME_MS) {
|
||||
if (SETTINGS.backShortToFileBrowser) {
|
||||
activityManager.goToFileBrowser(filePath);
|
||||
} else {
|
||||
goHome.fn(goHome.ctx);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace ReaderUtils
|
||||
|
||||
@@ -60,20 +60,15 @@ void TxtReaderActivity::onExit() {
|
||||
}
|
||||
|
||||
void TxtReaderActivity::loop() {
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, txt ? txt->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<TxtReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes directly to home
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput);
|
||||
|
||||
const bool atEndOfBook = currentPage >= xtc->getPageCount();
|
||||
|
||||
// While the end screen suggestion menu is showing it owns Confirm/Back/navigation
|
||||
@@ -97,24 +99,18 @@ void XtcReaderActivity::loop() {
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || ReaderUtils::isTouchMenuGesture(mappedInput)) {
|
||||
openChapterSelection();
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
|
||||
activityManager.goToFileBrowser(xtc ? xtc->getPath() : "");
|
||||
if (ReaderUtils::handleBackNavigation(mappedInput, activityManager, xtc ? xtc->getPath().c_str() : "",
|
||||
{this, [](void* ctx) { static_cast<XtcReaderActivity*>(ctx)->onGoHome(); }})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press BACK goes directly to home
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
onGoHome();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
auto [prevTriggered, nextTriggered, fromTilt] = ReaderUtils::detectPageTurn(mappedInput);
|
||||
prevTriggered = prevTriggered || touch.prev;
|
||||
nextTriggered = nextTriggered || touch.next;
|
||||
if (!prevTriggered && !nextTriggered) {
|
||||
return;
|
||||
}
|
||||
@@ -136,8 +132,9 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool skipPages = !fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP &&
|
||||
mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS;
|
||||
const unsigned long heldMs = (touch.prev || touch.next) ? touch.heldMs : mappedInput.getHeldTime();
|
||||
const bool skipPages =
|
||||
!fromTilt && SETTINGS.longPressButtonBehavior == SETTINGS.CHAPTER_SKIP && heldMs > ReaderUtils::SKIP_HOLD_MS;
|
||||
const int skipAmount = skipPages ? 10 : 1;
|
||||
|
||||
if (prevTriggered) {
|
||||
|
||||
@@ -56,17 +56,63 @@ void XtcReaderChapterSelectionActivity::loop() {
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = static_cast<int>(xtc->getChapters().size());
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
auto selectChapter = [this] {
|
||||
const auto& chapters = xtc->getChapters();
|
||||
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
|
||||
setResult(PageResult{chapters[selectorIndex].startPage});
|
||||
finish();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
};
|
||||
|
||||
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 = renderer.getScreenWidth() - hintGutterWidth;
|
||||
const int contentY = isPortraitInverted ? 50 : 0;
|
||||
const int listTop = 60 + contentY;
|
||||
int row = -1;
|
||||
const auto touch = mappedInput.rowTouch(row, listTop, 30, pageItems, contentX, contentX + contentWidth);
|
||||
if (touch != MappedInputManager::RowTouch::None) {
|
||||
const int touched = selectorIndex / pageItems * pageItems + row;
|
||||
if (touched >= 0 && touched < totalItems) {
|
||||
if (touch == MappedInputManager::RowTouch::Down) {
|
||||
if (selectorIndex != touched) {
|
||||
selectorIndex = touched;
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
selectorIndex = touched;
|
||||
selectChapter();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
selectChapter();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
|
||||
@@ -14,6 +14,14 @@ void ClearCacheActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
state = WARNING;
|
||||
const char* options[] = {tr(STR_CANCEL), tr(STR_CLEAR_BUTTON)};
|
||||
confirmPopup.show(tr(STR_CLEAR_READING_CACHE), options, 2, 0, [this](int idx) {
|
||||
if (idx == 1) {
|
||||
beginClear();
|
||||
} else {
|
||||
goBack();
|
||||
}
|
||||
});
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
@@ -35,6 +43,8 @@ void ClearCacheActivity::render(RenderLock&&) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, tr(STR_CLEAR_CACHE_WARNING_3), true);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, tr(STR_CLEAR_CACHE_WARNING_4), true);
|
||||
|
||||
if (confirmPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_CLEAR_BUTTON), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
@@ -73,6 +83,16 @@ void ClearCacheActivity::render(RenderLock&&) {
|
||||
}
|
||||
}
|
||||
|
||||
void ClearCacheActivity::beginClear() {
|
||||
LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear");
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = CLEARING;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
clearCache();
|
||||
}
|
||||
|
||||
void ClearCacheActivity::clearCache() {
|
||||
LOG_DBG("CLEAR_CACHE", "Clearing cache...");
|
||||
|
||||
@@ -122,15 +142,10 @@ void ClearCacheActivity::clearCache() {
|
||||
|
||||
void ClearCacheActivity::loop() {
|
||||
if (state == WARNING) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
LOG_DBG("CLEAR_CACHE", "User confirmed, starting cache clear");
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = CLEARING;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
clearCache();
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
beginClear();
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
@@ -141,7 +156,9 @@ void ClearCacheActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == SUCCESS || state == FAILED) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
goBack();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
|
||||
class ClearCacheActivity final : public Activity {
|
||||
public:
|
||||
@@ -24,5 +25,7 @@ class ClearCacheActivity final : public Activity {
|
||||
|
||||
int clearedCount = 0;
|
||||
int failedCount = 0;
|
||||
OptionPopup confirmPopup;
|
||||
void beginClear();
|
||||
void clearCache();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ constexpr uint8_t MAX_NEG_HOURS = 12;
|
||||
constexpr uint8_t MINUTE_STEPS = 4; // 0, 15, 30, 45
|
||||
constexpr uint8_t MINUTES_PER_QUARTER = 15;
|
||||
constexpr uint8_t BIAS_QUARTER_HOURS = 48; // 0 stored = UTC-12, 48 stored = UTC+0
|
||||
constexpr int TOUCH_BUTTON_SIZE = 44;
|
||||
constexpr int TOUCH_BUTTON_GAP = 18;
|
||||
|
||||
// Convert a (sign, hours, quarter) triple into the biased storage value.
|
||||
// Returns a value in [0, 104].
|
||||
@@ -43,6 +45,10 @@ void decodeOffset(uint8_t biased, uint8_t& sign, uint8_t& hours, uint8_t& quarte
|
||||
hours = static_cast<uint8_t>(signedQuarter / 4);
|
||||
quarter = static_cast<uint8_t>(signedQuarter % 4);
|
||||
}
|
||||
|
||||
bool contains(const Rect& rect, const int x, const int y) {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void ClockOffsetActivity::onEnter() {
|
||||
@@ -108,6 +114,71 @@ void ClockOffsetActivity::adjustActiveField(int delta) {
|
||||
}
|
||||
}
|
||||
|
||||
bool ClockOffsetActivity::fieldFromPoint(const int x, const int y, Field& field) const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const int centreY = pageHeight / 2 - 40;
|
||||
auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s, EpdFontFamily::BOLD); };
|
||||
constexpr int fieldPaddingX = 6;
|
||||
constexpr int labelGap = 16;
|
||||
constexpr int fieldGap = 12;
|
||||
constexpr int colonGap = 5;
|
||||
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int fieldHeight = lineHeight + 2;
|
||||
|
||||
const int labelWidth = widthOf("UTC");
|
||||
const int signBoxW = std::max(widthOf("+"), widthOf("-")) + fieldPaddingX * 2;
|
||||
const int hoursBoxW = std::max(widthOf("14"), widthOf("12")) + fieldPaddingX * 2;
|
||||
const int colonWidth = widthOf(":");
|
||||
const int minutesBoxW = std::max({widthOf("00"), widthOf("15"), widthOf("30"), widthOf("45")}) + fieldPaddingX * 2;
|
||||
const int totalWidth =
|
||||
labelWidth + labelGap + signBoxW + fieldGap + hoursBoxW + colonGap + colonWidth + colonGap + minutesBoxW;
|
||||
|
||||
int boxX = (pageWidth - totalWidth) / 2 + labelWidth + labelGap;
|
||||
auto hit = [&](const int width) {
|
||||
return x >= boxX && x < boxX + width && y >= centreY && y < centreY + fieldHeight;
|
||||
};
|
||||
if (hit(signBoxW)) {
|
||||
field = FIELD_SIGN;
|
||||
return true;
|
||||
}
|
||||
boxX += signBoxW + fieldGap;
|
||||
if (hit(hoursBoxW)) {
|
||||
field = FIELD_HOURS;
|
||||
return true;
|
||||
}
|
||||
boxX += hoursBoxW + colonGap + colonWidth + colonGap;
|
||||
if (hit(minutesBoxW)) {
|
||||
field = FIELD_MINUTES;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::getTouchControlRects(Rect& minusRect, Rect& plusRect) const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const int centreY = pageHeight / 2 - 40;
|
||||
auto widthOf = [&](const char* s) { return renderer.getTextWidth(UI_12_FONT_ID, s, EpdFontFamily::BOLD); };
|
||||
constexpr int fieldPaddingX = 6;
|
||||
constexpr int labelGap = 16;
|
||||
constexpr int fieldGap = 12;
|
||||
constexpr int colonGap = 5;
|
||||
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int fieldHeight = lineHeight + 2;
|
||||
const int labelWidth = widthOf("UTC");
|
||||
const int signBoxW = std::max(widthOf("+"), widthOf("-")) + fieldPaddingX * 2;
|
||||
const int hoursBoxW = std::max(widthOf("14"), widthOf("12")) + fieldPaddingX * 2;
|
||||
const int colonWidth = widthOf(":");
|
||||
const int minutesBoxW = std::max({widthOf("00"), widthOf("15"), widthOf("30"), widthOf("45")}) + fieldPaddingX * 2;
|
||||
const int totalWidth =
|
||||
labelWidth + labelGap + signBoxW + fieldGap + hoursBoxW + colonGap + colonWidth + colonGap + minutesBoxW;
|
||||
const int offsetX = (pageWidth - totalWidth) / 2;
|
||||
const int buttonY = centreY + (fieldHeight - TOUCH_BUTTON_SIZE) / 2;
|
||||
minusRect = Rect{offsetX - TOUCH_BUTTON_GAP - TOUCH_BUTTON_SIZE, buttonY, TOUCH_BUTTON_SIZE, TOUCH_BUTTON_SIZE};
|
||||
plusRect = Rect{offsetX + totalWidth + TOUCH_BUTTON_GAP, buttonY, TOUCH_BUTTON_SIZE, TOUCH_BUTTON_SIZE};
|
||||
}
|
||||
|
||||
void ClockOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
@@ -120,6 +191,53 @@ void ClockOffsetActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.hasTouch()) {
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
Rect minusRect;
|
||||
Rect plusRect;
|
||||
getTouchControlRects(minusRect, plusRect);
|
||||
|
||||
if (mappedInput.wasScreenTouchDown(tx, ty)) {
|
||||
if (contains(minusRect, tx, ty) || contains(plusRect, tx, ty)) {
|
||||
return;
|
||||
}
|
||||
Field touchedField = FIELD_HOURS;
|
||||
if (fieldFromPoint(tx, ty, touchedField)) {
|
||||
if (activeField != touchedField) {
|
||||
activeField = touchedField;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (contains(minusRect, tx, ty)) {
|
||||
adjustActiveField(-1);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (contains(plusRect, tx, ty)) {
|
||||
adjustActiveField(+1);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
Field touchedField = FIELD_HOURS;
|
||||
if (fieldFromPoint(tx, ty, touchedField)) {
|
||||
if (touchedField == FIELD_SIGN) {
|
||||
activeField = FIELD_SIGN;
|
||||
adjustActiveField(+1);
|
||||
} else {
|
||||
activeField = touchedField;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
adjustActiveField(+1);
|
||||
requestUpdate();
|
||||
@@ -196,6 +314,21 @@ void ClockOffsetActivity::render(RenderLock&&) {
|
||||
|
||||
drawField(minutesStr, x, minutesBoxW, FIELD_MINUTES);
|
||||
|
||||
if (mappedInput.hasTouch()) {
|
||||
Rect minusRect;
|
||||
Rect plusRect;
|
||||
getTouchControlRects(minusRect, plusRect);
|
||||
auto drawTouchButton = [&](const Rect& rect, const char* label) {
|
||||
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::White);
|
||||
renderer.drawRect(rect.x, rect.y, rect.width, rect.height, true);
|
||||
const int textX = rect.x + (rect.width - widthOf(label)) / 2;
|
||||
const int textY = rect.y + (rect.height - lineHeight) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true, EpdFontFamily::BOLD);
|
||||
};
|
||||
drawTouchButton(minusRect, "-");
|
||||
drawTouchButton(plusRect, "+");
|
||||
}
|
||||
|
||||
// Live preview of the resulting wall-clock time, so users can verify against a watch.
|
||||
if (halClock.isAvailable()) {
|
||||
char timeBuf[9];
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
struct Rect;
|
||||
|
||||
// Dedicated UTC offset picker for the status bar clock.
|
||||
// Three editable fields (sign, hours, minutes); Confirm cycles fields, Up/Down adjust the active one.
|
||||
// Supports the full IANA UTC offset range in 15 minute steps, including oddball zones like Nepal (+5:45).
|
||||
@@ -34,4 +38,6 @@ class ClockOffsetActivity final : public Activity {
|
||||
void saveToSettings() const;
|
||||
void adjustActiveField(int delta);
|
||||
void clampForSign();
|
||||
bool fieldFromPoint(int x, int y, Field& field) const;
|
||||
void getTouchControlRects(Rect& minusRect, Rect& plusRect) const;
|
||||
};
|
||||
|
||||
@@ -93,7 +93,9 @@ void ClockSyncActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +424,36 @@ bool FontDownloadActivity::isSelectedFamilyDeletable() const {
|
||||
|
||||
void FontDownloadActivity::loop() {
|
||||
if (state_ == FAMILY_LIST) {
|
||||
auto activateSelected = [this] {
|
||||
if (families_.empty()) return;
|
||||
if (isDownloadAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
downloadAll();
|
||||
} else if (isUpdateAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (f.hasUpdate) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
updateAll();
|
||||
} else {
|
||||
auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
downloadFamily(family);
|
||||
} else {
|
||||
promptDeleteSelectedFamily();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
};
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
@@ -432,6 +462,34 @@ void FontDownloadActivity::loop() {
|
||||
const int listSize = listItemCount();
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
if (!families_.empty()) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
switch (handleListTouch(selectedIndex_, listSize, contentTop, contentHeight, true)) {
|
||||
case ListTouchResult::Activated:
|
||||
activateSelected();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this, listSize] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
|
||||
requestUpdate();
|
||||
@@ -453,40 +511,14 @@ void FontDownloadActivity::loop() {
|
||||
});
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (!families_.empty()) {
|
||||
if (isDownloadAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
|
||||
downloadAll();
|
||||
} else if (isUpdateAllRow(selectedIndex_)) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (f.hasUpdate) currentFileTotal_ += f.files.size();
|
||||
}
|
||||
updateAll();
|
||||
} else {
|
||||
auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
downloadFamily(family);
|
||||
} else {
|
||||
promptDeleteSelectedFamily();
|
||||
return;
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
} else if (state_ == COMPLETE) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
@@ -512,6 +544,21 @@ void FontDownloadActivity::loop() {
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
} else {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasScreenTapped(x, y)) {
|
||||
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
|
||||
downloadFamily(families_[downloadingFamilyIndex_]);
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,16 +71,7 @@ void FontSelectionActivity::onEnter() {
|
||||
void FontSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void FontSelectionActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
SETTINGS.fontFamily = originalFontFamily_;
|
||||
strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
sdFontSystem.ensureLoaded(renderer);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
auto activateSelected = [this] {
|
||||
if (selectedIndex_ == previewFontIndex_) {
|
||||
handleSelection();
|
||||
} else {
|
||||
@@ -100,12 +91,48 @@ void FontSelectionActivity::loop() {
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
SETTINGS.fontFamily = originalFontFamily_;
|
||||
strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
sdFontSystem.ensureLoaded(renderer);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
const int listSize = static_cast<int>(fonts_.size());
|
||||
const int pageItems =
|
||||
UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing);
|
||||
const int listTop = afterHeader + previewHeight + metrics_.verticalSpacing;
|
||||
const int listHeight = usableHeight - previewHeight - metrics_.verticalSpacing;
|
||||
switch (handleListTouch(selectedIndex_, listSize, listTop, listHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
activateSelected();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex_ = ButtonNavigator::nextPageIndex(selectedIndex_, listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex_ = ButtonNavigator::previousPageIndex(selectedIndex_, listSize, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this, listSize] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
|
||||
|
||||
@@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = AUTHENTICATING;
|
||||
statusMessage = tr(STR_AUTHENTICATING);
|
||||
statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING);
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
@@ -34,16 +34,17 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
||||
}
|
||||
|
||||
void KOReaderAuthActivity::performAuthentication() {
|
||||
const auto result = KOReaderSyncClient::authenticate();
|
||||
const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate();
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
if (result == KOReaderSyncClient::OK) {
|
||||
state = SUCCESS;
|
||||
statusMessage = tr(STR_AUTH_SUCCESS);
|
||||
statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS);
|
||||
} else {
|
||||
state = FAILED;
|
||||
errorMessage = KOReaderSyncClient::errorString(result);
|
||||
errorMessage =
|
||||
result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result);
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
@@ -80,17 +81,21 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH));
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
|
||||
mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH));
|
||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto top = (pageHeight - height) / 2;
|
||||
|
||||
if (state == AUTHENTICATING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
||||
} else if (state == SUCCESS) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top,
|
||||
mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS), true,
|
||||
EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
||||
} else if (state == FAILED) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top, mode == Mode::SIGN_UP ? tr(STR_SIGNUP_FAILED) : tr(STR_AUTH_FAILED),
|
||||
true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
||||
}
|
||||
|
||||
@@ -101,8 +106,10 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
||||
|
||||
void KOReaderAuthActivity::loop() {
|
||||
if (state == SUCCESS || state == FAILED) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* Activity for testing KOReader credentials.
|
||||
* Connects to WiFi and authenticates with the KOReader sync server.
|
||||
* Activity for testing KOReader credentials, or — in sign-up mode — creating a
|
||||
* new account on the sync server with the entered username/password.
|
||||
* Connects to WiFi, then authenticates or registers.
|
||||
*/
|
||||
class KOReaderAuthActivity final : public Activity {
|
||||
public:
|
||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("KOReaderAuth", renderer, mappedInput) {}
|
||||
enum class Mode { AUTHENTICATE, SIGN_UP };
|
||||
|
||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE)
|
||||
: Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -22,6 +25,7 @@ class KOReaderAuthActivity final : public Activity {
|
||||
private:
|
||||
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
||||
|
||||
Mode mode = Mode::AUTHENTICATE;
|
||||
State state = WIFI_SELECTION;
|
||||
std::string statusMessage;
|
||||
std::string errorMessage;
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int MENU_ITEMS = 6;
|
||||
constexpr int MENU_ITEMS = 8;
|
||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
|
||||
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE};
|
||||
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
|
||||
StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE};
|
||||
} // namespace
|
||||
|
||||
void KOReaderSettingsActivity::onEnter() {
|
||||
@@ -28,13 +29,27 @@ void KOReaderSettingsActivity::onEnter() {
|
||||
void KOReaderSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void KOReaderSettingsActivity::loop() {
|
||||
auto activateSelected = [this] { handleSelection(); };
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
int touchSel = static_cast<int>(selectedIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, MENU_ITEMS, contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -103,6 +118,22 @@ void KOReaderSettingsActivity::handleSelection() {
|
||||
KOREADER_STORE.saveToFile();
|
||||
requestUpdate();
|
||||
} else if (selectedIndex == 5) {
|
||||
// Sync behavior - toggle between Ask and Smart
|
||||
const auto current = KOREADER_STORE.getSyncBehavior();
|
||||
const auto newBehavior = (current == KOReaderSyncBehavior::ASK_EVERY_TIME) ? KOReaderSyncBehavior::SMART
|
||||
: KOReaderSyncBehavior::ASK_EVERY_TIME;
|
||||
KOREADER_STORE.setSyncBehavior(newBehavior);
|
||||
KOREADER_STORE.saveToFile();
|
||||
requestUpdate();
|
||||
} else if (selectedIndex == 6) {
|
||||
// Sign Up - create a new account on the sync server with the entered credentials
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
return;
|
||||
}
|
||||
startActivityForResult(
|
||||
std::make_unique<KOReaderAuthActivity>(renderer, mappedInput, KOReaderAuthActivity::Mode::SIGN_UP),
|
||||
[](const ActivityResult&) {});
|
||||
} else if (selectedIndex == 7) {
|
||||
// Authenticate
|
||||
if (!KOREADER_STORE.hasCredentials()) {
|
||||
// Can't authenticate without credentials - just show message briefly
|
||||
@@ -136,13 +167,25 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
||||
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 2) {
|
||||
auto serverUrl = KOREADER_STORE.getServerUrl();
|
||||
return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl;
|
||||
if (!serverUrl.empty()) {
|
||||
return serverUrl;
|
||||
}
|
||||
// Show which server the default actually is, scheme stripped for space
|
||||
std::string defaultUrl = KOREADER_STORE.getBaseUrl();
|
||||
const auto schemeEnd = defaultUrl.find("://");
|
||||
if (schemeEnd != std::string::npos) {
|
||||
defaultUrl.erase(0, schemeEnd + 3);
|
||||
}
|
||||
return std::string(tr(STR_DEFAULT_VALUE)) + ": " + defaultUrl;
|
||||
} else if (index == 3) {
|
||||
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
||||
: std::string(tr(STR_BINARY));
|
||||
} else if (index == 4) {
|
||||
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
|
||||
} else if (index == 5) {
|
||||
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
|
||||
: std::string(tr(STR_ASK_EVERY_TIME));
|
||||
} else if (index == 6 || index == 7) {
|
||||
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
|
||||
}
|
||||
return std::string(tr(STR_NOT_SET));
|
||||
|
||||
@@ -27,17 +27,44 @@ void LanguageSelectActivity::onEnter() {
|
||||
void LanguageSelectActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void LanguageSelectActivity::loop() {
|
||||
auto activateSelected = [this] { handleSelection(); };
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
switch (handleListTouch(selectedIndex, totalItems, contentTop, contentHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
activateSelected();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex = ButtonNavigator::nextPageIndex(static_cast<int>(selectedIndex), totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(static_cast<int>(selectedIndex), totalItems, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
|
||||
@@ -3,19 +3,51 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "activities/ActivityManager.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/OpdsFilename.h"
|
||||
|
||||
namespace {
|
||||
// Normalizes a user-typed folder: trims spaces, "" => SD root, otherwise a
|
||||
// single leading '/' and no trailing '/'. Cold path (runs once per edit).
|
||||
std::string normalizeFolder(std::string v) {
|
||||
while (!v.empty() && (v.front() == ' ' || v.front() == '\t')) v.erase(v.begin());
|
||||
while (!v.empty() && (v.back() == ' ' || v.back() == '\t')) v.pop_back();
|
||||
if (v.empty()) return "";
|
||||
if (v.front() != '/') v.insert(v.begin(), '/');
|
||||
while (v.size() > 1 && v.back() == '/') v.pop_back();
|
||||
if (v == "/") return ""; // a bare slash is SD root, same as empty
|
||||
return v;
|
||||
}
|
||||
|
||||
// Label shown for the current OPDS filename format in the list subtitle.
|
||||
StrId opdsFormatLabel(uint8_t format) {
|
||||
switch (format) {
|
||||
case static_cast<uint8_t>(OpdsFilenameFormat::TitleAuthor):
|
||||
return StrId::STR_FMT_TITLE_AUTHOR;
|
||||
case static_cast<uint8_t>(OpdsFilenameFormat::TitleOnly):
|
||||
return StrId::STR_FMT_TITLE;
|
||||
default:
|
||||
return StrId::STR_FMT_AUTHOR_TITLE;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int OpdsServerListActivity::getItemCount() const {
|
||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||
// In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers
|
||||
// Settings mode appends three virtual items: "Add Server", "Download folder"
|
||||
// and "Filename format".
|
||||
if (!pickerMode) {
|
||||
count++;
|
||||
count += 3;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -32,6 +64,8 @@ void OpdsServerListActivity::onEnter() {
|
||||
void OpdsServerListActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsServerListActivity::loop() {
|
||||
auto activateSelected = [this] { handleSelection(); };
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
if (pickerMode) {
|
||||
activityManager.goHome(HomeMenuItem::OPDS_BROWSER);
|
||||
@@ -42,12 +76,39 @@ void OpdsServerListActivity::loop() {
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
activateSelected();
|
||||
return;
|
||||
}
|
||||
|
||||
const int itemCount = getItemCount();
|
||||
if (itemCount > 0) {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
switch (handleListTouch(selectedIndex, itemCount, contentTop, contentHeight, true)) {
|
||||
case ListTouchResult::Activated:
|
||||
activateSelected();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
const int pageItems = GUI.getListPageItems(contentHeight, true);
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, itemCount, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, itemCount, pageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
@@ -74,6 +135,34 @@ void OpdsServerListActivity::handleSelection() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Index layout: [servers 0..serverCount-1], [Add Server], [Download folder], [Filename format].
|
||||
if (selectedIndex == serverCount + 1) {
|
||||
auto folderHandler = [this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(result.data);
|
||||
const std::string norm = normalizeFolder(kb.text);
|
||||
strncpy(SETTINGS.opdsDownloadFolder, norm.c_str(), sizeof(SETTINGS.opdsDownloadFolder) - 1);
|
||||
SETTINGS.opdsDownloadFolder[sizeof(SETTINGS.opdsDownloadFolder) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
requestUpdate();
|
||||
}
|
||||
};
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_DOWNLOAD_FOLDER),
|
||||
std::string(SETTINGS.opdsDownloadFolder), 63, InputType::Text),
|
||||
folderHandler);
|
||||
return;
|
||||
}
|
||||
|
||||
// "Filename format": tap cycles through the available formats.
|
||||
if (selectedIndex == serverCount + 2) {
|
||||
SETTINGS.opdsFilenameFormat =
|
||||
static_cast<uint8_t>((SETTINGS.opdsFilenameFormat + 1) % static_cast<uint8_t>(OpdsFilenameFormat::Count));
|
||||
SETTINGS.saveToFile();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Settings mode: open editor for selected server, or create a new one
|
||||
auto resultHandler = [this](const ActivityResult&) {
|
||||
// Reload server list when returning from editor
|
||||
@@ -111,17 +200,30 @@ void OpdsServerListActivity::render(RenderLock&&) {
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) {
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
if (index == serverCount) {
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
}
|
||||
if (index == serverCount + 1) {
|
||||
return std::string(I18n::getInstance().get(StrId::STR_OPDS_DOWNLOAD_FOLDER));
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_OPDS_FILENAME_FORMAT));
|
||||
},
|
||||
[&servers, serverCount](int index) {
|
||||
[&servers, serverCount](int index) -> std::string {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
return servers[index].url;
|
||||
}
|
||||
if (index == serverCount + 1) {
|
||||
const char* f = SETTINGS.opdsDownloadFolder;
|
||||
return f[0] ? std::string(f) : std::string(I18n::getInstance().get(StrId::STR_OPDS_SD_ROOT));
|
||||
}
|
||||
if (index == serverCount + 2) {
|
||||
return std::string(I18n::getInstance().get(opdsFormatLabel(SETTINGS.opdsFilenameFormat)));
|
||||
}
|
||||
return std::string("");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,20 @@ void OpdsSettingsActivity::onEnter() {
|
||||
void OpdsSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsSettingsActivity::loop() {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
int touchSel = static_cast<int>(selectedIndex);
|
||||
const auto listTouch = handleListTouch(touchSel, menuItems, contentTop, contentHeight, false);
|
||||
if (listTouch != ListTouchResult::None) {
|
||||
selectedIndex = static_cast<size_t>(touchSel);
|
||||
if (listTouch == ListTouchResult::Activated) handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
@@ -58,7 +72,6 @@ void OpdsSettingsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuItems = getMenuItemCount();
|
||||
buttonNavigator.onNext([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + 1) % menuItems;
|
||||
requestUpdate();
|
||||
|
||||
@@ -11,6 +11,23 @@
|
||||
#include "fontIds.h"
|
||||
#include "network/OtaUpdater.h"
|
||||
|
||||
namespace {
|
||||
struct OtaActionRects {
|
||||
Rect cancel;
|
||||
Rect update;
|
||||
};
|
||||
|
||||
OtaActionRects getOtaActionRects(const GfxRenderer& renderer) {
|
||||
const int top = renderer.getScreenHeight() - 80;
|
||||
const int width = renderer.getScreenWidth() / 2;
|
||||
return {Rect{0, top, width, 80}, Rect{width, top, renderer.getScreenWidth() - width, 80}};
|
||||
}
|
||||
|
||||
bool contains(const Rect& rect, const int x, const int y) {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void OtaUpdateActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
LOG_ERR("OTA", "WiFi connection failed, exiting");
|
||||
@@ -109,6 +126,14 @@ void OtaUpdateActivity::render(RenderLock&&) {
|
||||
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, top + height * 2 + metrics.verticalSpacing * 2,
|
||||
(std::string(tr(STR_NEW_VERSION)) + updater.getLatestVersion()).c_str());
|
||||
|
||||
const auto actionRects = getOtaActionRects(renderer);
|
||||
const int cancelTextWidth = renderer.getTextWidth(UI_10_FONT_ID, tr(STR_CANCEL));
|
||||
renderer.drawText(UI_10_FONT_ID, actionRects.cancel.x + (actionRects.cancel.width - cancelTextWidth) / 2,
|
||||
actionRects.cancel.y + 28, tr(STR_CANCEL));
|
||||
const int updateTextWidth = renderer.getTextWidth(UI_10_FONT_ID, tr(STR_UPDATE));
|
||||
renderer.drawText(UI_10_FONT_ID, actionRects.update.x + (actionRects.update.width - updateTextWidth) / 2,
|
||||
actionRects.update.y + 28, tr(STR_UPDATE));
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_UPDATE), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else if (state == UPDATE_IN_PROGRESS) {
|
||||
@@ -143,45 +168,64 @@ void OtaUpdateActivity::render(RenderLock&&) {
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void OtaUpdateActivity::runUpdateInstall() {
|
||||
LOG_DBG("OTA", "New update available, starting download...");
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = UPDATE_IN_PROGRESS;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
const auto res = updater.installUpdate(
|
||||
[](void* ctx) {
|
||||
// immediate=true notifies the render task directly. The default deferred path only
|
||||
// sets a flag consumed at the end of ActivityManager::loop(), which never runs while
|
||||
// installUpdate() blocks this task.
|
||||
static_cast<OtaUpdateActivity*>(ctx)->requestUpdate(true);
|
||||
},
|
||||
this);
|
||||
|
||||
if (res != OtaUpdater::OK) {
|
||||
LOG_DBG("OTA", "Update failed: %d", res);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = FAILED;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = FINISHED;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
// Hold the completion screen briefly so the user sees it, then restart.
|
||||
delay(3000);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SHUTTING_DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
void OtaUpdateActivity::loop() {
|
||||
if (state == WAITING_CONFIRMATION) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
LOG_DBG("OTA", "New update available, starting download...");
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = UPDATE_IN_PROGRESS;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
const auto res = updater.installUpdate(
|
||||
[](void* ctx) {
|
||||
// immediate=true notifies the render task directly. The default deferred path only
|
||||
// sets a flag consumed at the end of ActivityManager::loop(), which never runs while
|
||||
// installUpdate() blocks this task.
|
||||
static_cast<OtaUpdateActivity*>(ctx)->requestUpdate(true);
|
||||
},
|
||||
this);
|
||||
|
||||
if (res != OtaUpdater::OK) {
|
||||
LOG_DBG("OTA", "Update failed: %d", res);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = FAILED;
|
||||
}
|
||||
requestUpdate();
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasScreenTapped(x, y)) {
|
||||
const auto actionRects = getOtaActionRects(renderer);
|
||||
if (contains(actionRects.cancel, x, y)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (contains(actionRects.update, x, y)) {
|
||||
runUpdateInstall();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = FINISHED;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
// Hold the completion screen briefly so the user sees it, then restart.
|
||||
delay(3000);
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SHUTTING_DOWN;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
runUpdateInstall();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
@@ -192,14 +236,18 @@ void OtaUpdateActivity::loop() {
|
||||
}
|
||||
|
||||
if (state == FAILED) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == NO_UPDATE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || mappedInput.wasScreenTapped(x, y)) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,7 @@ class OtaUpdateActivity : public Activity {
|
||||
OtaUpdater updater;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
void runUpdateInstall();
|
||||
|
||||
public:
|
||||
explicit OtaUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
@@ -186,8 +186,10 @@ void SdFirmwareUpdateActivity::performUpdate() {
|
||||
|
||||
void SdFirmwareUpdateActivity::loop() {
|
||||
if (state == State::FAILED) {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm) || mappedInput.wasScreenTapped(x, y)) {
|
||||
if (recoveryMode) {
|
||||
// Go back to picker so user can try a different .bin
|
||||
state = State::PICKING;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "SettingsActivity.h"
|
||||
|
||||
#include <BoardConfig.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <Logging.h>
|
||||
|
||||
@@ -39,7 +40,12 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
// reader activity ran — otherwise the font-family picker shows stale list.
|
||||
sdFontSystem.refreshIfDirty();
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
|
||||
// Rescan /dictionaries on every rebuild: cheap (one directory listing) and
|
||||
// picks up dictionaries copied to the SD card since the last visit.
|
||||
std::vector<DictionaryEntry> dictionaries;
|
||||
DictionaryRegistry::discover(dictionaries);
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry(), &dictionaries)) {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(setting);
|
||||
@@ -57,13 +63,18 @@ void SettingsActivity::rebuildSettingsLists() {
|
||||
}
|
||||
|
||||
// Append device-only ACTION items
|
||||
controlsSettings.insert(controlsSettings.begin(),
|
||||
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
|
||||
if (!BoardConfig::hasTouch()) {
|
||||
controlsSettings.insert(controlsSettings.begin(),
|
||||
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
|
||||
}
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||
// TODO: Touch devices need their own firmware update path/artifacts before OTA is exposed.
|
||||
if (!BoardConfig::hasTouch()) {
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||
}
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
||||
// Insert "Manage Fonts" right after the font family setting so users discover it naturally
|
||||
@@ -117,6 +128,24 @@ void SettingsActivity::loop() {
|
||||
|
||||
bool hasChangedCategory = false;
|
||||
|
||||
auto applyCategorySelection = [this] {
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0:
|
||||
currentSettings = &displaySettings;
|
||||
break;
|
||||
case 1:
|
||||
currentSettings = &readerSettings;
|
||||
break;
|
||||
case 2:
|
||||
currentSettings = &controlsSettings;
|
||||
break;
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
};
|
||||
|
||||
// Handle actions with early return
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedSettingIndex == 0) {
|
||||
@@ -141,7 +170,103 @@ void SettingsActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
const int tabTop = metrics.topPadding + metrics.headerHeight;
|
||||
const int listTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing;
|
||||
const int listHeight =
|
||||
renderer.getScreenHeight() - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight +
|
||||
metrics.buttonHintsHeight + metrics.verticalSpacing * 2);
|
||||
auto buildTabs = [&]() {
|
||||
std::vector<TabInfo> tabs;
|
||||
tabs.reserve(categoryCount);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
tabs.push_back({I18N.get(categoryNames[i]), selectedCategoryIndex == i});
|
||||
}
|
||||
return tabs;
|
||||
};
|
||||
auto settingIndexFromPoint = [&](const int x, const int y, int& settingIndex) {
|
||||
(void)x;
|
||||
if (settingsCount <= 0 || y < listTop || y >= listTop + listHeight) return false;
|
||||
const int rowStep = GUI.getListRowStep(false);
|
||||
if (rowStep <= 0) return false;
|
||||
const int pageItems = GUI.getListPageItems(listHeight, false);
|
||||
const int selectedRow = std::max(0, selectedSettingIndex - 1);
|
||||
const int pageStart = selectedRow / pageItems * pageItems;
|
||||
const int row = (y - listTop) / rowStep;
|
||||
const int touched = pageStart + row;
|
||||
if (row < 0 || row >= pageItems || touched < 0 || touched >= settingsCount) return false;
|
||||
settingIndex = touched + 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
if (mappedInput.wasScreenTouchDown(tx, ty)) {
|
||||
int touchedCategory = -1;
|
||||
const auto tabs = buildTabs();
|
||||
if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty,
|
||||
touchedCategory)) {
|
||||
if (selectedCategoryIndex != touchedCategory || selectedSettingIndex != 0) {
|
||||
selectedCategoryIndex = touchedCategory;
|
||||
selectedSettingIndex = 0;
|
||||
applyCategorySelection();
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int touchedSetting = -1;
|
||||
if (settingIndexFromPoint(tx, ty, touchedSetting)) {
|
||||
if (selectedSettingIndex != touchedSetting) {
|
||||
selectedSettingIndex = touchedSetting;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasScreenTapped(tx, ty)) {
|
||||
int tappedCategory = -1;
|
||||
const auto tabs = buildTabs();
|
||||
if (GUI.tabIndexFromPoint(renderer, Rect{0, tabTop, renderer.getScreenWidth(), metrics.tabBarHeight}, tabs, tx, ty,
|
||||
tappedCategory)) {
|
||||
selectedCategoryIndex = tappedCategory;
|
||||
selectedSettingIndex = 0;
|
||||
applyCategorySelection();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
int tappedSetting = -1;
|
||||
if (settingIndexFromPoint(tx, ty, tappedSetting)) {
|
||||
selectedSettingIndex = tappedSetting;
|
||||
toggleCurrentSetting();
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
const auto& navMetrics = UITheme::getInstance().getMetrics();
|
||||
const int settingsListHeight =
|
||||
renderer.getScreenHeight() - (navMetrics.topPadding + navMetrics.headerHeight + navMetrics.tabBarHeight +
|
||||
navMetrics.buttonHintsHeight + navMetrics.verticalSpacing * 2);
|
||||
const int settingsPageItems = GUI.getListPageItems(settingsListHeight, false);
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Up) {
|
||||
selectedSettingIndex = selectedSettingIndex == 0 ? 1
|
||||
: ButtonNavigator::nextPageIndex(
|
||||
selectedSettingIndex, settingsCount + 1, settingsPageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Down) {
|
||||
selectedSettingIndex =
|
||||
ButtonNavigator::previousPageIndex(selectedSettingIndex, settingsCount + 1, settingsPageItems);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
selectedSettingIndex = ButtonNavigator::nextIndex(selectedSettingIndex, settingsCount + 1);
|
||||
requestUpdate();
|
||||
@@ -166,21 +291,7 @@ void SettingsActivity::loop() {
|
||||
|
||||
if (hasChangedCategory) {
|
||||
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0:
|
||||
currentSettings = &displaySettings;
|
||||
break;
|
||||
case 1:
|
||||
currentSettings = &readerSettings;
|
||||
break;
|
||||
case 2:
|
||||
currentSettings = &controlsSettings;
|
||||
break;
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
applyCategorySelection();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,21 @@ void StatusBarSettingsActivity::onExit() { Activity::onExit(); }
|
||||
void StatusBarSettingsActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
switch (handleListTouch(selectedIndex, visibleItemCount, contentTop, contentHeight, false)) {
|
||||
case ListTouchResult::Activated:
|
||||
handleSelection();
|
||||
requestUpdate();
|
||||
return;
|
||||
case ListTouchResult::Consumed:
|
||||
return;
|
||||
case ListTouchResult::None:
|
||||
break;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
|
||||
@@ -180,11 +180,37 @@ void BmpViewerActivity::loop() {
|
||||
// Keep CPU awake/polling so 1st click works
|
||||
Activity::loop();
|
||||
|
||||
auto openSibling = [this](const int delta) {
|
||||
if (currentImageIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
const int nextIndex = currentImageIndex + delta;
|
||||
if (siblingImages.size() <= 1 || nextIndex < 0 || nextIndex >= static_cast<int>(siblingImages.size())) {
|
||||
return false;
|
||||
}
|
||||
currentImageIndex = nextIndex;
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
if (dirPath.back() != '/') dirPath += "/";
|
||||
filePath = dirPath + siblingImages[currentImageIndex];
|
||||
onEnter();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
activityManager.goToFileBrowser(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto swipe = mappedInput.wasSwipe();
|
||||
if (swipe == MappedInputManager::SwipeDir::Left) {
|
||||
openSibling(1);
|
||||
return;
|
||||
}
|
||||
if (swipe == MappedInputManager::SwipeDir::Right) {
|
||||
openSibling(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
doSetSleepCover();
|
||||
return;
|
||||
@@ -192,26 +218,13 @@ void BmpViewerActivity::loop() {
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Left) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up)) {
|
||||
if (siblingImages.size() > 1 && currentImageIndex > 0) {
|
||||
currentImageIndex--;
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
if (dirPath.back() != '/') dirPath += "/";
|
||||
filePath = dirPath + siblingImages[currentImageIndex];
|
||||
onEnter();
|
||||
}
|
||||
openSibling(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Right) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||
if (siblingImages.size() > 1 && currentImageIndex != -1 &&
|
||||
currentImageIndex < static_cast<int>(siblingImages.size()) - 1) {
|
||||
currentImageIndex++;
|
||||
std::string dirPath = FsHelpers::extractFolderPath(filePath);
|
||||
if (dirPath.back() != '/') dirPath += "/";
|
||||
filePath = dirPath + siblingImages[currentImageIndex];
|
||||
onEnter();
|
||||
}
|
||||
openSibling(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,17 @@ void ConfirmationActivity::onEnter() {
|
||||
safeBody = renderer.truncatedText(fontId, body.c_str(), maxWidth, EpdFontFamily::REGULAR);
|
||||
}
|
||||
|
||||
int totalHeight = 0;
|
||||
if (!safeHeading.empty()) totalHeight += lineHeight;
|
||||
if (!safeBody.empty()) totalHeight += lineHeight;
|
||||
if (!safeHeading.empty() && !safeBody.empty()) totalHeight += spacing;
|
||||
// Text sits in the upper part of the screen so the confirmation popup
|
||||
// (centered) doesn't cover it.
|
||||
startY = renderer.getScreenHeight() / 6;
|
||||
|
||||
startY = (renderer.getScreenHeight() - totalHeight) / 2;
|
||||
const char* options[] = {I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM)};
|
||||
confirmPopup.show(safeHeading.c_str(), options, 2, 0, [this](int idx) {
|
||||
ActivityResult res;
|
||||
res.isCancelled = (idx != 1);
|
||||
setResult(std::move(res));
|
||||
finish();
|
||||
});
|
||||
|
||||
requestUpdate(true);
|
||||
}
|
||||
@@ -48,27 +53,17 @@ void ConfirmationActivity::render(RenderLock&& lock) {
|
||||
renderer.drawCenteredText(fontId, currentY, safeBody.c_str(), true, EpdFontFamily::REGULAR);
|
||||
}
|
||||
|
||||
// Draw UI Elements
|
||||
const auto labels = mappedInput.mapLabels("", "", I18N.get(StrId::STR_CANCEL), I18N.get(StrId::STR_CONFIRM));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
if (confirmPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
renderer.displayBuffer(HalDisplay::RefreshMode::FAST_REFRESH);
|
||||
}
|
||||
|
||||
void ConfirmationActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
ActivityResult res;
|
||||
res.isCancelled = false;
|
||||
setResult(std::move(res));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (confirmPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
ActivityResult res;
|
||||
res.isCancelled = true;
|
||||
setResult(std::move(res));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Popup dismissed without a selection (Back button or tap outside): cancel.
|
||||
ActivityResult res;
|
||||
res.isCancelled = true;
|
||||
setResult(std::move(res));
|
||||
finish();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
class ConfirmationActivity : public Activity {
|
||||
@@ -17,6 +18,7 @@ class ConfirmationActivity : public Activity {
|
||||
|
||||
std::string safeHeading;
|
||||
std::string safeBody;
|
||||
OptionPopup confirmPopup;
|
||||
int startY = 0;
|
||||
int lineHeight = 0;
|
||||
|
||||
|
||||
@@ -49,6 +49,36 @@ void IntervalSelectionActivity::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
int tx = 0;
|
||||
int ty = 0;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int barWidth = std::min(360, std::max(0, screenWidth - 40));
|
||||
constexpr int barHeight = 16;
|
||||
const int barX = std::max(0, (screenWidth - barWidth) / 2);
|
||||
const int barY = 140;
|
||||
|
||||
// Live drag on the slider: once a touch lands on the bar, the value follows the
|
||||
// finger until release. Runs before the Back/Confirm handlers because the release
|
||||
// of a drag can also register as a swipe (e.g. the left-edge rightward back
|
||||
// gesture) — the drag must consume it so it can't cancel or confirm the dialog.
|
||||
if (mappedInput.isScreenTouchHeld(tx, ty)) {
|
||||
if (draggingBar || (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth)) {
|
||||
draggingBar = true;
|
||||
const int range = std::max(1, maxValue - minValue);
|
||||
const int dragged =
|
||||
clampedValue(minValue + std::clamp(tx - barX, 0, barWidth - 1) * range / std::max(1, barWidth - 1));
|
||||
if (dragged != value) {
|
||||
value = dragged;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (draggingBar) {
|
||||
// Release frame of a drag: swallow the tap/swipe events it produced.
|
||||
draggingBar = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
@@ -63,6 +93,27 @@ void IntervalSelectionActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasScreenTapped(tx, ty)) {
|
||||
if (ty >= barY - 20 && ty < barY + barHeight + 20 && tx >= barX && tx < barX + barWidth) {
|
||||
const int range = std::max(1, maxValue - minValue);
|
||||
value = clampedValue(minValue + (tx - barX) * range / std::max(1, barWidth - 1));
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (ty >= renderer.getScreenHeight() - 80) {
|
||||
if (tx < renderer.getScreenWidth() / 3) {
|
||||
ActivityResult result;
|
||||
result.isCancelled = true;
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
} else if (tx > renderer.getScreenWidth() * 2 / 3) {
|
||||
setResult(IntervalResult{static_cast<uint32_t>(value)});
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ class IntervalSelectionActivity final : public Activity {
|
||||
int largeStep;
|
||||
bool readerActivity;
|
||||
bool ignoreConfirmRelease;
|
||||
bool draggingBar = false;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
void adjustValue(int delta);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,21 @@
|
||||
#pragma once
|
||||
#include <FreeInkUIGfxRenderer.h>
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
struct KeyDef {
|
||||
char primary;
|
||||
char secondary;
|
||||
};
|
||||
|
||||
enum class SpecialKeyType { Shift, Mode, Space, Del, Ok };
|
||||
|
||||
enum class InputType { Text, Password, Url };
|
||||
|
||||
// Text entry on the FreeInkUI keyboard component: the SDK layout tables and
|
||||
// keyboard() do the key rendering and hit-rect registration, InteractionBuffer
|
||||
// routes taps/long-presses, and this activity owns the text field, cursor
|
||||
// editing, and the URL snippet layouts.
|
||||
class KeyboardEntryActivity : public Activity {
|
||||
public:
|
||||
explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
@@ -43,16 +41,30 @@ class KeyboardEntryActivity : public Activity {
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
int selectedRow = 0;
|
||||
int selectedCol = 0;
|
||||
int shiftState = 0;
|
||||
bool symMode = false;
|
||||
// Keyboard layers. The letter/symbol layers come from the SDK's builtin
|
||||
// layouts (with the always-visible number row); the URL layers are
|
||||
// app-defined tables in the .cpp.
|
||||
freeink::ui::KeyboardLayoutId layoutId = freeink::ui::KeyboardLayoutId::QwertyEn;
|
||||
bool shifted = false;
|
||||
bool symbols = false;
|
||||
bool urlPanel = false; // URL snippet panel replaces the letter layer
|
||||
|
||||
// Key hit rects registered by the keyboard component during render();
|
||||
// loop() routes touch snapshots against them. 5-row EN layout registers 41
|
||||
// keys, so 48 leaves headroom.
|
||||
freeink::ui::InteractionBuffer<48> interactions;
|
||||
|
||||
// GPIO selection over the current layout grid (row/col in layout terms;
|
||||
// the bottom action row is just the last row).
|
||||
int selRow = 0;
|
||||
int selCol = 0;
|
||||
|
||||
bool confirmHeld = false;
|
||||
bool confirmLongHandled = false;
|
||||
|
||||
bool cursorMode = false;
|
||||
bool togglePos = false;
|
||||
size_t cursorPos = 0;
|
||||
size_t cursorPos = 0; // byte offset into text (always on a code point boundary)
|
||||
bool upHeld = false;
|
||||
bool upLongHandled = false;
|
||||
bool downHeld = false;
|
||||
@@ -62,10 +74,15 @@ class KeyboardEntryActivity : public Activity {
|
||||
size_t savedCursorPos = 0;
|
||||
size_t rightStartCursorPos = 0;
|
||||
|
||||
bool urlMode = false;
|
||||
static constexpr int URL_SNIPPET_COUNT = 9;
|
||||
static constexpr const char* const urlSnippets[URL_SNIPPET_COUNT] = {
|
||||
"https://", "www.", ".com", "http://", "192.168.", ".org", "/opds", ":8080", ".net"};
|
||||
// Tap/hold routing (threshold long-press, release swallow, slide re-arm)
|
||||
// lives in the SDK; loop() feeds it the level-triggered touch state.
|
||||
freeink::ui::TouchHoldRouter touchRouter;
|
||||
|
||||
// loop() runs on the main task while render() rebuilds the interaction
|
||||
// table on the render task; routing against a half-built table would read
|
||||
// torn entries, so taps are dropped during the rebuild window. atomic (not
|
||||
// volatile) so the flag also orders the table writes on dual-core targets.
|
||||
std::atomic<bool> interactionsReady{false};
|
||||
|
||||
int delPressCount = 0;
|
||||
bool hintVisible = false;
|
||||
@@ -73,154 +90,40 @@ class KeyboardEntryActivity : public Activity {
|
||||
|
||||
void onComplete(std::string text);
|
||||
void onCancel();
|
||||
bool cursorPositionFromPoint(int x, int y, size_t& position) const;
|
||||
std::string displayTextForCurrentState() const;
|
||||
// Advance of s[start, end) measured in place by temporarily null-terminating
|
||||
// at `end` — avoids a substr temporary per measurement.
|
||||
int measureRange(std::string& s, int start, int end) const;
|
||||
// Largest line end in (start, s.length()] whose advance fits maxWidth.
|
||||
// Binary search over the monotonic prefix advance; always advances at least
|
||||
// one byte so an oversized glyph cannot stall the wrap loop.
|
||||
int lineBreakEnd(std::string& s, int start, int maxWidth) const;
|
||||
|
||||
const freeink::ui::KeyboardLayout& currentLayout() const;
|
||||
const freeink::ui::KeyboardKey* selectedKey() const;
|
||||
int selectedLogicalIndex() const;
|
||||
void clampSelection();
|
||||
void moveSelectionRow(int delta);
|
||||
void moveSelectionCol(int delta);
|
||||
bool syncSelectionToValue(int16_t value);
|
||||
// Handles one key activation (by stable key id). Returns true when the
|
||||
// screen needs a repaint; OK/cancel finish the activity instead.
|
||||
bool activateValue(int16_t value, bool longPress);
|
||||
bool clearAllOrAltOnSelected();
|
||||
|
||||
void insertUtf8(const char* out);
|
||||
bool backspaceUtf8();
|
||||
static size_t utf8Prev(const std::string& s, size_t pos);
|
||||
static size_t utf8Next(const std::string& s, size_t pos);
|
||||
|
||||
freeink::ui::Rect keyboardRect() const;
|
||||
|
||||
static constexpr uint16_t LONG_PRESS_MS = 500;
|
||||
static constexpr uint16_t DEL_LONG_PRESS_MS = 1500;
|
||||
static constexpr uint16_t TOUCH_LONG_PRESS_MS = 350;
|
||||
static constexpr uint16_t TOUCH_DEL_LONG_PRESS_MS = 900;
|
||||
|
||||
static constexpr int COLS = 10;
|
||||
static constexpr int ABC_ROWS = 4;
|
||||
static constexpr int SYM_ROWS = 4;
|
||||
static constexpr int BOTTOM_KEY_COUNT = 5;
|
||||
|
||||
static constexpr KeyDef abcLayout[ABC_ROWS][COLS] = {
|
||||
{{'1', '!'},
|
||||
{'2', '@'},
|
||||
{'3', '#'},
|
||||
{'4', '$'},
|
||||
{'5', '%'},
|
||||
{'6', '^'},
|
||||
{'7', '&'},
|
||||
{'8', '*'},
|
||||
{'9', '('},
|
||||
{'0', ')'}},
|
||||
{{'q', 'Q'},
|
||||
{'w', 'W'},
|
||||
{'e', 'E'},
|
||||
{'r', 'R'},
|
||||
{'t', 'T'},
|
||||
{'y', 'Y'},
|
||||
{'u', 'U'},
|
||||
{'i', 'I'},
|
||||
{'o', 'O'},
|
||||
{'p', 'P'}},
|
||||
{{'a', 'A'},
|
||||
{'s', 'S'},
|
||||
{'d', 'D'},
|
||||
{'f', 'F'},
|
||||
{'g', 'G'},
|
||||
{'h', 'H'},
|
||||
{'j', 'J'},
|
||||
{'k', 'K'},
|
||||
{'l', 'L'},
|
||||
{'-', '_'}},
|
||||
{{'z', 'Z'},
|
||||
{'x', 'X'},
|
||||
{'c', 'C'},
|
||||
{'v', 'V'},
|
||||
{'b', 'B'},
|
||||
{'n', 'N'},
|
||||
{'m', 'M'},
|
||||
{'=', '+'},
|
||||
{'.', '>'},
|
||||
{',', '<'}},
|
||||
};
|
||||
|
||||
static constexpr KeyDef urlLayout[ABC_ROWS][COLS] = {
|
||||
{{'1', '!'},
|
||||
{'2', '@'},
|
||||
{'3', '#'},
|
||||
{'4', '$'},
|
||||
{'5', '%'},
|
||||
{'6', '^'},
|
||||
{'7', '&'},
|
||||
{'8', '*'},
|
||||
{'9', '('},
|
||||
{'0', ')'}},
|
||||
{{'q', 'Q'},
|
||||
{'w', 'W'},
|
||||
{'e', 'E'},
|
||||
{'r', 'R'},
|
||||
{'t', 'T'},
|
||||
{'y', 'Y'},
|
||||
{'u', 'U'},
|
||||
{'i', 'I'},
|
||||
{'o', 'O'},
|
||||
{'p', 'P'}},
|
||||
{{'a', 'A'},
|
||||
{'s', 'S'},
|
||||
{'d', 'D'},
|
||||
{'f', 'F'},
|
||||
{'g', 'G'},
|
||||
{'h', 'H'},
|
||||
{'j', 'J'},
|
||||
{'k', 'K'},
|
||||
{'l', 'L'},
|
||||
{'-', '_'}},
|
||||
{{'z', 'Z'},
|
||||
{'x', 'X'},
|
||||
{'c', 'C'},
|
||||
{'v', 'V'},
|
||||
{'b', 'B'},
|
||||
{'n', 'N'},
|
||||
{'m', 'M'},
|
||||
{':', '+'},
|
||||
{'.', '>'},
|
||||
{'/', '<'}},
|
||||
};
|
||||
|
||||
static constexpr KeyDef symLayout[SYM_ROWS][COLS] = {
|
||||
{{'1', '\0'},
|
||||
{'2', '\0'},
|
||||
{'3', '\0'},
|
||||
{'4', '\0'},
|
||||
{'5', '\0'},
|
||||
{'6', '\0'},
|
||||
{'7', '\0'},
|
||||
{'8', '\0'},
|
||||
{'9', '\0'},
|
||||
{'0', '\0'}},
|
||||
{{'!', '\0'},
|
||||
{'@', '\0'},
|
||||
{'#', '\0'},
|
||||
{'$', '\0'},
|
||||
{'%', '\0'},
|
||||
{'^', '\0'},
|
||||
{'&', '\0'},
|
||||
{'*', '\0'},
|
||||
{'(', '\0'},
|
||||
{')', '\0'}},
|
||||
{{'-', '\0'},
|
||||
{'_', '\0'},
|
||||
{'=', '\0'},
|
||||
{'+', '\0'},
|
||||
{'[', '\0'},
|
||||
{']', '\0'},
|
||||
{'{', '\0'},
|
||||
{'}', '\0'},
|
||||
{';', '\0'},
|
||||
{':', '\0'}},
|
||||
{{'\'', '\0'},
|
||||
{'"', '\0'},
|
||||
{'/', '\0'},
|
||||
{'\\', '\0'},
|
||||
{'|', '\0'},
|
||||
{'?', '\0'},
|
||||
{'.', '\0'},
|
||||
{',', '\0'},
|
||||
{'~', '\0'},
|
||||
{'`', '\0'}},
|
||||
};
|
||||
|
||||
static const char* const shiftString[2];
|
||||
|
||||
int getContentRowCount() const;
|
||||
int getContentColCount() const;
|
||||
int getTotalRowCount() const;
|
||||
bool isBottomRow(int row) const;
|
||||
char getSelectedChar() const;
|
||||
char getAlternativeChar() const;
|
||||
bool handleKeyPress();
|
||||
bool insertChar(char c);
|
||||
void insertString(const std::string& str);
|
||||
void mapColContentBottom(int& col, bool goingUp) const;
|
||||
// App-specific key id: toggles the URL snippet panel (URL fields only).
|
||||
static constexpr int16_t URL_PANEL_KEY = -3;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user