Merge remote-tracking branch 'origin/develop' into feat-bluetooth
# Conflicts: # .gitmodules # freeink-sdk # platformio.ini # src/activities/reader/EpubReaderActivity.cpp
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
#include "I18nKeys.h"
|
||||
@@ -96,6 +97,7 @@ uint8_t CrossPointSettings::sleepTimeoutEnumToMinutes(const uint8_t legacyValue)
|
||||
}
|
||||
|
||||
bool CrossPointSettings::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON);
|
||||
}
|
||||
@@ -106,7 +108,11 @@ bool CrossPointSettings::loadFromFile() {
|
||||
String json = Storage.readFile(SETTINGS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
bool resave = false;
|
||||
bool result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
bool result;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
result = JsonSettingsIO::loadSettings(*this, json.c_str(), &resave);
|
||||
}
|
||||
if (result && resave) {
|
||||
if (saveToFile()) {
|
||||
LOG_DBG("CPS", "Resaved settings to update format");
|
||||
@@ -166,6 +172,7 @@ bool CrossPointSettings::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", SETTINGS_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <iosfwd>
|
||||
#include <mutex>
|
||||
|
||||
class CrossPointSettings {
|
||||
private:
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Private constructor for singleton
|
||||
CrossPointSettings() = default;
|
||||
|
||||
@@ -17,6 +20,10 @@ class CrossPointSettings {
|
||||
CrossPointSettings(const CrossPointSettings&) = delete;
|
||||
CrossPointSettings& operator=(const CrossPointSettings&) = delete;
|
||||
|
||||
// Access the settings mutex for protecting multi-field reads/writes from other cores.
|
||||
// Callers must not re-enter SETTINGS methods that lock _mutex while holding it.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
enum SLEEP_SCREEN_MODE {
|
||||
DARK = 0,
|
||||
LIGHT = 1,
|
||||
@@ -65,6 +72,8 @@ class CrossPointSettings {
|
||||
XTC_STATUS_BAR_MODE_COUNT
|
||||
};
|
||||
|
||||
enum STATUS_BAR_CLOCK_MODE { STATUS_BAR_CLOCK_HIDE = 0, STATUS_BAR_CLOCK_RIGHT = 1, STATUS_BAR_CLOCK_LEFT = 2 };
|
||||
|
||||
enum ORIENTATION {
|
||||
PORTRAIT = 0, // 480x800 logical coordinates (current default)
|
||||
LANDSCAPE_CW = 1, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
||||
@@ -188,7 +197,7 @@ class CrossPointSettings {
|
||||
uint8_t statusBarBattery = 1;
|
||||
uint8_t xtcStatusBarMode = XTC_STATUS_BAR_HIDE;
|
||||
// Clock display in status bar (X3 only, requires DS3231 RTC)
|
||||
uint8_t statusBarClock = 0;
|
||||
uint8_t statusBarClock = STATUS_BAR_CLOCK_HIDE;
|
||||
// Clock UTC offset in quarter-hour steps, biased by 48 so it fits in uint8_t.
|
||||
// Value 48 = UTC+0, 0 = UTC-12:00, 104 = UTC+14:00.
|
||||
// Quarter-hour granularity supports oddball zones like Nepal (+5:45) and Chatham (+12:45).
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <mutex>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t STATE_FILE_VERSION = 4;
|
||||
@@ -32,6 +33,7 @@ void CrossPointState::pushRecentSleep(uint16_t idx) {
|
||||
}
|
||||
|
||||
bool CrossPointState::saveToFile() const {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
Storage.mkdir("/.crosspoint");
|
||||
return JsonSettingsIO::saveState(*this, STATE_FILE_JSON);
|
||||
}
|
||||
@@ -41,6 +43,7 @@ bool CrossPointState::loadFromFile() {
|
||||
if (Storage.exists(STATE_FILE_JSON)) {
|
||||
String json = Storage.readFile(STATE_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
return JsonSettingsIO::loadState(*this, json.c_str());
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,7 @@ bool CrossPointState::loadFromBinaryFile() {
|
||||
if (!Storage.openFileForRead("CPS", STATE_FILE_BIN, inputFile)) {
|
||||
return false;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(_mutex);
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
class CrossPointState {
|
||||
mutable std::mutex _mutex;
|
||||
|
||||
// Static instance
|
||||
static CrossPointState instance;
|
||||
|
||||
public:
|
||||
// Access the state mutex for protecting multi-field reads/writes from other cores.
|
||||
std::mutex& getMutex() const { return _mutex; }
|
||||
|
||||
static constexpr uint8_t SLEEP_RECENT_COUNT = 16;
|
||||
|
||||
std::string openEpubPath;
|
||||
|
||||
@@ -166,10 +166,6 @@ bool JsonSettingsIO::saveSettings(const CrossPointSettings& s, const char* path)
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
// Language -- managed by LanguageSelectActivity, not in SettingsList.
|
||||
// Stored as ISO code string ("EN", "DE", ...) for stability across enum reorders.
|
||||
doc["language"] = (s.language < getLanguageCount()) ? LANGUAGE_CODES[s.language] : "EN";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
@@ -375,6 +371,7 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json)
|
||||
|
||||
store.recentBooks.clear();
|
||||
JsonArray arr = doc["books"].as<JsonArray>();
|
||||
store.recentBooks.reserve(std::min(arr.size(), (size_t)10));
|
||||
for (JsonObject obj : arr) {
|
||||
if (store.getCount() >= 10) break;
|
||||
RecentBook book;
|
||||
|
||||
@@ -34,10 +34,12 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found on card: %s (clearing)", SETTINGS.sdFontFamilyName);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,7 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
LOG_DBG("SDFS", "SD font family disappeared: %s (clearing)", wantedFamily);
|
||||
manager_.unloadAll(renderer);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
return;
|
||||
}
|
||||
const auto* selected = family->findClosestReaderSize(sizeEnum);
|
||||
@@ -96,10 +99,12 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) {
|
||||
} else {
|
||||
LOG_ERR("SDFS", "Failed to load SD font family: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("SDFS", "SD font family not found: %s (clearing)", wantedFamily);
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -104,8 +104,8 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
std::vector<SettingInfo> v = {
|
||||
// --- Display ---
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_SCREEN, &CrossPointSettings::sleepScreen,
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER, StrId::STR_NONE_OPT,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_QUICK_RESUME},
|
||||
{StrId::STR_DARK, StrId::STR_LIGHT, StrId::STR_CUSTOM, StrId::STR_COVER,
|
||||
StrId::STR_COVER_CUSTOM, StrId::STR_NONE_OPT, StrId::STR_QUICK_RESUME},
|
||||
"sleepScreen", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_SLEEP_COVER_MODE, &CrossPointSettings::sleepScreenCoverMode,
|
||||
{StrId::STR_FIT, StrId::STR_CROP}, "sleepScreenCoverMode", StrId::STR_CAT_DISPLAY),
|
||||
@@ -151,9 +151,10 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_HYPHENATION, &CrossPointSettings::hyphenationEnabled, "hyphenationEnabled",
|
||||
StrId::STR_CAT_READER),
|
||||
SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
"orientation", StrId::STR_CAT_READER),
|
||||
SettingInfo::Enum(
|
||||
StrId::STR_ORIENTATION, &CrossPointSettings::orientation,
|
||||
{StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_ORIENTATION_INVERTED, StrId::STR_LANDSCAPE_CCW},
|
||||
"orientation", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_EXTRA_SPACING, &CrossPointSettings::extraParagraphSpacing,
|
||||
"extraParagraphSpacing", StrId::STR_CAT_READER),
|
||||
SettingInfo::Toggle(StrId::STR_TEXT_AA, &CrossPointSettings::textAntiAliasing, "textAntiAliasing",
|
||||
@@ -244,8 +245,9 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
// Clock entries (web settings only; device UI uses ClockOffsetActivity for the offset).
|
||||
// Range 0..104 = quarter-hour steps from UTC-12:00 to UTC+14:00, biased by 48.
|
||||
SettingInfo::Toggle(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock, "statusBarClock",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Enum(StrId::STR_CLOCK, &CrossPointSettings::statusBarClock,
|
||||
{StrId::STR_HIDE, StrId::STR_DIR_LEFT, StrId::STR_DIR_RIGHT}, "statusBarClock",
|
||||
StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Value(StrId::STR_CLOCK_UTC_OFFSET, &CrossPointSettings::clockUtcOffsetQ, {0, 104, 1},
|
||||
"clockUtcOffsetQ", StrId::STR_CUSTOMISE_STATUS_BAR),
|
||||
SettingInfo::Enum(StrId::STR_CLOCK_FORMAT, &CrossPointSettings::clockFormat,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
// Initialize the static instance
|
||||
WifiCredentialStore WifiCredentialStore::instance;
|
||||
|
||||
@@ -89,6 +91,7 @@ bool WifiCredentialStore::loadFromBinaryFile() {
|
||||
serialization::readPod(file, count);
|
||||
|
||||
credentials.clear();
|
||||
credentials.reserve(std::min<size_t>(count, MAX_NETWORKS));
|
||||
for (uint8_t i = 0; i < count && i < MAX_NETWORKS; i++) {
|
||||
WifiCredential cred;
|
||||
serialization::readString(file, cred.ssid);
|
||||
|
||||
@@ -19,12 +19,15 @@
|
||||
#include "settings/SettingsActivity.h"
|
||||
#include "util/FullScreenMessageActivity.h"
|
||||
|
||||
static portMUX_TYPE activityManagerSpinlock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
void ActivityManager::begin() {
|
||||
xTaskCreate(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle // Task handle
|
||||
xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender",
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&renderTaskHandle, // Task handle
|
||||
0 // Pin to core 0 (PRO_CPU)
|
||||
);
|
||||
assert(renderTaskHandle != nullptr && "Failed to create render task");
|
||||
}
|
||||
@@ -46,10 +49,10 @@ void ActivityManager::renderTaskLoop() {
|
||||
}
|
||||
// Notify any task blocked in requestUpdateAndWait() that the render is done.
|
||||
TaskHandle_t waiter = nullptr;
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
waiter = waitingTaskHandle;
|
||||
waitingTaskHandle = nullptr;
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
if (waiter) {
|
||||
xTaskNotify(waiter, 1, eIncrement);
|
||||
}
|
||||
@@ -137,8 +140,7 @@ void ActivityManager::loop() {
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedUpdate) {
|
||||
requestedUpdate = false;
|
||||
if (requestedUpdate.exchange(false)) {
|
||||
// Using direct notification to signal the render task to update
|
||||
// Increment counter so multiple rapid calls won't be lost
|
||||
if (renderTaskHandle) {
|
||||
@@ -295,7 +297,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
}
|
||||
|
||||
// Atomic section to perform checks
|
||||
taskENTER_CRITICAL(nullptr);
|
||||
taskENTER_CRITICAL(&activityManagerSpinlock);
|
||||
auto currTaskHandler = xTaskGetCurrentTaskHandle();
|
||||
auto mutexHolder = xSemaphoreGetMutexHolder(renderingMutex);
|
||||
bool isRenderTask = (currTaskHandler == renderTaskHandle);
|
||||
@@ -304,7 +306,7 @@ void ActivityManager::requestUpdateAndWait() {
|
||||
if (!alreadyWaiting && !isRenderTask && !holdingRenderLock) {
|
||||
waitingTaskHandle = currTaskHandler;
|
||||
}
|
||||
taskEXIT_CRITICAL(nullptr);
|
||||
taskEXIT_CRITICAL(&activityManagerSpinlock);
|
||||
|
||||
// Render task cannot call requestUpdateAndWait() or it will cause a deadlock
|
||||
assert(!isRenderTask && "Render task cannot call requestUpdateAndWait()");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
@@ -63,7 +64,7 @@ class ActivityManager {
|
||||
|
||||
// Whether to trigger a render after the current loop()
|
||||
// This variable must only be set by the main loop, to avoid race conditions
|
||||
bool requestedUpdate = false;
|
||||
std::atomic<bool> requestedUpdate{false};
|
||||
|
||||
public:
|
||||
explicit ActivityManager(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
@@ -43,6 +43,10 @@ struct PageResult {
|
||||
struct ProgressChangeResult {
|
||||
int spineIndex = 0;
|
||||
int page = 0;
|
||||
int totalPages = 0;
|
||||
std::string xpath;
|
||||
float percentage = 0.0f;
|
||||
bool hasSavedProgress = false;
|
||||
};
|
||||
|
||||
enum class NetworkMode;
|
||||
|
||||
@@ -163,7 +163,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
||||
renderer.invertScreen();
|
||||
}
|
||||
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
@@ -219,13 +219,11 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
// OEM grayscale pipeline base: on X3 this displays the frame with the
|
||||
// dedicated "AA-pre-BW(mid)" differential waveform, leaving every pixel
|
||||
// in the calibrated state the gray nudge refresh expects; on X4 it is a
|
||||
// plain HALF refresh (previous behavior).
|
||||
renderer.displayGrayscaleBase(HalDisplay::HALF_REFRESH);
|
||||
// OEM grayscale pipeline base: use a full sleep-screen paint so the panel
|
||||
// enters deep sleep from a clean B/W baseline before the gray nudge refresh.
|
||||
renderer.displayGrayscaleBase(HalDisplay::FULL_REFRESH);
|
||||
} else {
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
if (hasGreyscale) {
|
||||
@@ -333,5 +331,5 @@ void SleepActivity::renderLastScreenSleepScreen() const {
|
||||
|
||||
void SleepActivity::renderBlankSleepScreen() const {
|
||||
renderer.clearScreen();
|
||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||
renderer.displayBuffer(HalDisplay::FULL_REFRESH);
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
std::string getFileExtension(const std::string& filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -410,7 +410,8 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
||||
startY += height10 + metrics.verticalSpacing * 2;
|
||||
|
||||
// Show QR code for Wifi
|
||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||
// follows spec at https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11
|
||||
const std::string wifiConfig = std::string("WIFI:T:nopass;S:") + connectedSSID + ";;";
|
||||
const Rect qrBoundsWifi(metrics.contentSidePadding, startY, QR_CODE_WIDTH, QR_CODE_HEIGHT);
|
||||
QrUtils::drawQrCode(renderer, qrBoundsWifi, wifiConfig);
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "EndOfBookOptions.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "ReaderUtils.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
#include "util/NextBookFinder.h"
|
||||
|
||||
namespace {
|
||||
// Display name without the file extension, mirroring the file browser rows
|
||||
std::string displayName(const std::string& filename) {
|
||||
const auto pos = filename.rfind('.');
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EndOfBookOptions::loadOnce(const std::string& currentBookPath) {
|
||||
if (isLoaded.load(std::memory_order_acquire)) {
|
||||
return;
|
||||
}
|
||||
folder = FsHelpers::extractFolderPath(currentBookPath);
|
||||
names = NextBookFinder::findNextBooks(currentBookPath, MAX_SUGGESTIONS);
|
||||
selector = 0;
|
||||
// Release-publish so the main task, which gates all access on isLoaded, never
|
||||
// observes a partially built list
|
||||
isLoaded.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool EndOfBookOptions::menuActive() const { return isLoaded.load(std::memory_order_acquire) && !names.empty(); }
|
||||
|
||||
std::string EndOfBookOptions::fullPath(const size_t index) const {
|
||||
if (index >= names.size()) {
|
||||
return {};
|
||||
}
|
||||
return folder == "/" ? "/" + names[index] : folder + "/" + names[index];
|
||||
}
|
||||
|
||||
EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputManager& input, std::string* openPath) {
|
||||
if (input.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selector < static_cast<int>(names.size())) {
|
||||
if (openPath) {
|
||||
*openPath = fullPath(selector);
|
||||
}
|
||||
return Action::OpenBook;
|
||||
}
|
||||
return Action::GoHome; // "Home" entry selected
|
||||
}
|
||||
|
||||
// Short-press Back returns to the last page; a long press falls through to the
|
||||
// reader's own handler (file browser). Home is reached through the list's Home entry.
|
||||
if (input.wasReleased(MappedInputManager::Button::Back) && input.getHeldTime() < ReaderUtils::GO_HOME_MS) {
|
||||
return Action::LastPage;
|
||||
}
|
||||
|
||||
// Selection movement on the standard list navigation buttons (side Up/Down plus front
|
||||
// Left/Right, orientation swap included). It follows the reader's page-turn semantics
|
||||
// (press-triggered by default, release-triggered when a long-press behavior is
|
||||
// configured, same rule as ReaderUtils::detectPageTurn). This matters on entry: with
|
||||
// press-triggered turns, the press that turned the final page already fired in the
|
||||
// reader, and its release must not double-fire into this menu.
|
||||
const bool usePress = SETTINGS.longPressButtonBehavior == CrossPointSettings::OFF;
|
||||
const auto triggered = [&](const MappedInputManager::Button button) {
|
||||
return usePress ? input.wasPressed(button) : input.wasReleased(button);
|
||||
};
|
||||
const int itemCount = static_cast<int>(names.size()) + 1; // + "Home" entry
|
||||
if (triggered(MappedInputManager::Button::NavPrevious)) {
|
||||
selector = ButtonNavigator::previousIndex(selector, itemCount); // wraps to the bottom
|
||||
return Action::Redraw;
|
||||
}
|
||||
if (triggered(MappedInputManager::Button::NavNext)) {
|
||||
selector = ButtonNavigator::nextIndex(selector, itemCount); // wraps to the top
|
||||
return Action::Redraw;
|
||||
}
|
||||
return Action::None;
|
||||
}
|
||||
|
||||
void EndOfBookOptions::render(GfxRenderer& renderer, const MappedInputManager& input) const {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
if (!menuActive()) {
|
||||
// No suggestions: the historical plain end screen. 3/8 of the screen height matches
|
||||
// the previous fixed position on the 480x800 panel and scales to other resolutions.
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() * 3 / 8, tr(STR_END_OF_BOOK), true,
|
||||
EpdFontFamily::BOLD);
|
||||
return;
|
||||
}
|
||||
|
||||
// Suggestion menu: title, list (+ Home entry) and button hints. The hints are drawn at
|
||||
// the physical front buttons, which is a logical side/top edge in the rotated
|
||||
// orientations — lay out inside the safe area so nothing hides behind them. Vertical
|
||||
// positions derive from the safe-area height and font line heights so other panel
|
||||
// resolutions scale (review request on #2532).
|
||||
const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false);
|
||||
const int titleY = safe.y + safe.height / 8;
|
||||
const int subtitleY = titleY + renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing;
|
||||
const int listTop = subtitleY + renderer.getLineHeight(UI_10_FONT_ID) + metrics.verticalSpacing * 2;
|
||||
|
||||
UITheme::drawCenteredText(renderer, safe, UI_12_FONT_ID, titleY, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
UITheme::drawCenteredText(renderer, safe, UI_10_FONT_ID, subtitleY, tr(STR_EOB_CONTINUE_WITH));
|
||||
|
||||
const int listHeight = safe.y + safe.height - listTop - metrics.verticalSpacing;
|
||||
GUI.drawList(renderer, Rect{safe.x, listTop, safe.width, listHeight}, static_cast<int>(names.size()) + 1, selector,
|
||||
[this](const int index) {
|
||||
return index < static_cast<int>(names.size()) ? displayName(names[index])
|
||||
: std::string(tr(STR_EOB_HOME));
|
||||
});
|
||||
|
||||
const auto labels = input.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class GfxRenderer;
|
||||
class MappedInputManager;
|
||||
|
||||
// Shared End-of-Book next-book menu for the EPUB and XTC readers. Collects up to
|
||||
// MAX_SUGGESTIONS sibling books once per reader session, handles the menu input, and
|
||||
// draws the end screen. With no suggestions the end screen keeps its historical
|
||||
// plain-title look and behavior.
|
||||
class EndOfBookOptions {
|
||||
public:
|
||||
enum class Action { None, Redraw, OpenBook, GoHome, LastPage };
|
||||
|
||||
static constexpr size_t MAX_SUGGESTIONS = 3;
|
||||
|
||||
// Scans the book's folder for suggestions; no-op when already loaded. Call ONLY from
|
||||
// the reader's render() (the render task, serialized by RenderLock) — the loaded flag
|
||||
// is the release/acquire publication point that lets the main task read the finished
|
||||
// list safely.
|
||||
void loadOnce(const std::string& currentBookPath);
|
||||
|
||||
// True when the suggestion menu is showing and should own the reader's input.
|
||||
bool menuActive() const;
|
||||
|
||||
// Menu input handling, following the standard list idiom: side Up/Down and front
|
||||
// Left/Right move the selection (wrapping), Confirm opens it (or Home), and a short
|
||||
// Back press returns to the last page of the book. Fills openPath when the result is
|
||||
// OpenBook. Returns Action::None when nothing relevant was pressed; callers continue
|
||||
// their normal input path (keeping long-press Back to the file browser working).
|
||||
Action handleMenuInput(const MappedInputManager& input, std::string* openPath);
|
||||
|
||||
// Draws the full end screen (plain title, or the suggestion menu) onto a cleared buffer.
|
||||
void render(GfxRenderer& renderer, const MappedInputManager& input) const;
|
||||
|
||||
private:
|
||||
std::string folder;
|
||||
// Written by the render task in loadOnce(), immutable afterwards; the main task only
|
||||
// reads it after isLoaded is observed true (acquire), so no further locking is needed.
|
||||
std::vector<std::string> names;
|
||||
int selector = 0;
|
||||
std::atomic<bool> isLoaded{false};
|
||||
|
||||
std::string fullPath(size_t index) const;
|
||||
};
|
||||
@@ -84,9 +84,10 @@ ProgressRange getPageProgressRange(const std::shared_ptr<Epub>& epub, const int
|
||||
return {epub->calculateProgress(spineIndex, start), epub->calculateProgress(spineIndex, end)};
|
||||
}
|
||||
|
||||
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const SavedProgressPosition& progress,
|
||||
bool bookmarkMatchesProgress(const BookmarkEntry& bookmark, const int spineIndex, const int page, const int pageCount,
|
||||
const ProgressRange& pageRange) {
|
||||
if (bookmark.xpath == progress.xpath) {
|
||||
if (bookmark.computedSpineIndex == spineIndex && bookmark.computedChapterPageCount == pageCount &&
|
||||
bookmark.computedChapterProgress == page) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -231,6 +232,30 @@ void EpubReaderActivity::onExit() {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::openReaderMenu() {
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : 0;
|
||||
float bookProgress = 0.0f;
|
||||
if (epub->getBookSize() > 0 && section && section->estimatedTotalPages() > 0) {
|
||||
const float chapterProgress =
|
||||
static_cast<float>(section->currentPage) / static_cast<float>(section->estimatedTotalPages());
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderActivity::loop() {
|
||||
if (!epub) {
|
||||
// Should never happen
|
||||
@@ -238,6 +263,33 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drive any in-progress incremental section build forward, off the page-turn critical path,
|
||||
// but only within a small window ahead of the reader: an unbounded build monopolized the
|
||||
// RenderLock and locked out page turns. The build follows the reader instead, and instant
|
||||
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
|
||||
// Skip while the render mutex is busy so we never delay a pending render; re-check
|
||||
// isBuilding() under the lock since render() may have just finished it.
|
||||
if (section && section->isBuilding() && !RenderLock::peek() &&
|
||||
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) {
|
||||
RenderLock lock;
|
||||
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
|
||||
// build between the outer isBuilding() check and acquiring the lock here, in which case
|
||||
// buildSomeMore() would fail and wrongly reset the section. cppcheck can't see the cross-task
|
||||
// mutation, so it flags this as always true.
|
||||
// cppcheck-suppress knownConditionTrueFalse
|
||||
if (section->isBuilding()) {
|
||||
if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) {
|
||||
LOG_ERR("ERS", "Background section build failed");
|
||||
section.reset();
|
||||
requestUpdate();
|
||||
} else if (section->isBuildComplete() && applyDeferredReposition()) {
|
||||
// The chapter re-paginated since the saved progress (settings changed): we now know the
|
||||
// real page count, so re-render at the remapped page. No-op for an unchanged resume.
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-Book screen reached (currentSpineIndex == spine count) means the book is
|
||||
// finished. Two independent finished-book features key off this same condition.
|
||||
const bool atEndOfBook = currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount();
|
||||
@@ -298,6 +350,35 @@ void EpubReaderActivity::loop() {
|
||||
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
|
||||
// block. A Confirm release after a long-press function (bookmark/sync) fired is left
|
||||
// to the regular Confirm handler below, which consumes it via ignoreNextConfirmRelease.
|
||||
if (atEndOfBook && endOfBookOptions.menuActive() &&
|
||||
!(ignoreNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm))) {
|
||||
std::string openPath;
|
||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
||||
case EndOfBookOptions::Action::OpenBook:
|
||||
activityManager.goToReader(openPath);
|
||||
return;
|
||||
case EndOfBookOptions::Action::GoHome:
|
||||
onGoHome();
|
||||
return;
|
||||
case EndOfBookOptions::Action::LastPage:
|
||||
currentSpineIndex = std::max(epub->getSpineItemsCount() - 1, 0);
|
||||
nextPageNumber = 0;
|
||||
pendingPageJump = std::numeric_limits<uint16_t>::max();
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::Redraw:
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// following the hold does not also open the menu.
|
||||
@@ -305,26 +386,7 @@ void EpubReaderActivity::loop() {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
const int currentPage = section ? section->currentPage + 1 : 0;
|
||||
const int totalPages = section ? section->pageCount : 0;
|
||||
float bookProgress = 0.0f;
|
||||
if (epub->getBookSize() > 0 && section && section->pageCount > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||
}
|
||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||
startActivityForResult(std::make_unique<EpubReaderMenuActivity>(
|
||||
renderer, mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||
SETTINGS.orientation, !currentPageFootnotes.empty(), !cachedBookmarks.empty()),
|
||||
[this](const ActivityResult& result) {
|
||||
// Always apply orientation change even if the menu was cancelled
|
||||
const auto& menu = std::get<MenuResult>(result.data);
|
||||
applyOrientation(menu.orientation);
|
||||
toggleAutoPageTurn(menu.pageTurnOption);
|
||||
if (!result.isCancelled) {
|
||||
onReaderMenuConfirm(static_cast<EpubReaderMenuActivity::MenuAction>(menu.action));
|
||||
}
|
||||
});
|
||||
openReaderMenu();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,8 +467,14 @@ void EpubReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// At end of the book, forward button goes home and back button returns to last page
|
||||
// At end of the book with no suggestion menu, forward button goes home and back
|
||||
// button returns to last page
|
||||
if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) {
|
||||
if (endOfBookOptions.menuActive()) {
|
||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
||||
return;
|
||||
}
|
||||
if (nextTriggered) {
|
||||
onGoHome();
|
||||
} else {
|
||||
@@ -537,11 +605,32 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
||||
loadCachedBookmarks();
|
||||
if (!result.isCancelled) {
|
||||
const auto& sync = std::get<ProgressChangeResult>(result.data);
|
||||
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
|
||||
int targetSpineIndex = sync.spineIndex;
|
||||
int targetPage = sync.page;
|
||||
const int activeTotalPages = section ? section->estimatedTotalPages() : 0;
|
||||
const bool cachedPageMatchesActiveSection = section && sync.totalPages > 0 &&
|
||||
currentSpineIndex == sync.spineIndex && sync.page >= 0 &&
|
||||
sync.page < sync.totalPages && activeTotalPages == sync.totalPages;
|
||||
|
||||
if (!cachedPageMatchesActiveSection && sync.hasSavedProgress) {
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
CrossPointPosition fallback =
|
||||
ProgressMapper::toCrossPoint(epub, {sync.xpath, sync.percentage}, renderer, currentSpineIndex, totalPages);
|
||||
targetSpineIndex = fallback.spineIndex;
|
||||
targetPage = fallback.pageNumber;
|
||||
}
|
||||
|
||||
if (currentSpineIndex != targetSpineIndex) {
|
||||
RenderLock lock(*this);
|
||||
currentSpineIndex = sync.spineIndex;
|
||||
nextPageNumber = sync.page;
|
||||
currentSpineIndex = targetSpineIndex;
|
||||
nextPageNumber = targetPage;
|
||||
section.reset();
|
||||
} else if (section && section->currentPage != targetPage) {
|
||||
RenderLock lock(*this);
|
||||
const int clampedTargetPage = std::max(0, targetPage);
|
||||
section->currentPage = clampedTargetPage;
|
||||
} else if (!section) {
|
||||
nextPageNumber = targetPage;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -661,7 +750,7 @@ bool EpubReaderActivity::launchKOReaderSync() {
|
||||
if (!KOREADER_STORE.hasCredentials()) return false; // no-op: nothing to launch
|
||||
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
@@ -759,7 +848,12 @@ void EpubReaderActivity::toggleAutoPageTurn(const uint8_t selectedPageTurnOption
|
||||
|
||||
void EpubReaderActivity::pageTurn(bool isForwardTurn) {
|
||||
if (isForwardTurn) {
|
||||
if (section->currentPage < section->pageCount - 1) {
|
||||
// Advance within the section while there are (or may still be) more pages: either a built
|
||||
// page ahead, or the section is still building (windowed), in which case more pages exist
|
||||
// beyond the current watermark and render()'s ensure-built pump will lay them out. Only when
|
||||
// the section is fully built AND we're on its last page do we move to the next spine -- using
|
||||
// the live pageCount alone would mistake the build watermark for the end of a giant spine.
|
||||
if (section->currentPage < section->pageCount - 1 || section->isBuilding()) {
|
||||
section->currentPage++;
|
||||
} else {
|
||||
// We don't want to delete the section mid-render, so grab the semaphore
|
||||
@@ -811,8 +905,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
|
||||
// Show end of book screen
|
||||
if (currentSpineIndex == epub->getSpineItemsCount()) {
|
||||
// Sole load site: runs on the render task (serialized by RenderLock); the main
|
||||
// task only reads the suggestions once the loaded flag is published
|
||||
endOfBookOptions.loadOnce(epub->getPath());
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
endOfBookOptions.render(renderer, mappedInput);
|
||||
renderer.displayBuffer();
|
||||
automaticPageTurnActive = false;
|
||||
showPendingSyncSaveError();
|
||||
@@ -847,33 +944,39 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
|
||||
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
|
||||
|
||||
if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
// A finalized cache serves every page as-is. A partial cache (suspended build from a
|
||||
// previous session) serves its pages instantly too, but a build must still run to lay
|
||||
// out the rest -- it re-parses from the top in the background (HTML already cached,
|
||||
// pages are deterministic) and finalizes, so the partial machinery retires itself.
|
||||
const bool cacheLoaded = section->loadSectionFile(
|
||||
SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing,
|
||||
SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||
if (cacheLoaded) {
|
||||
// Matching render params means identical pagination, so the saved page number is valid
|
||||
// as-is: consume any pending settings-change reposition. Without this, a chapter total
|
||||
// saved while the section was still building (i.e. a watermark, not the real count)
|
||||
// would remap the resume page against the finalized count and teleport the reader.
|
||||
cachedChapterTotalPageCount = 0;
|
||||
}
|
||||
const bool cacheComplete = cacheLoaded && !section->isPartial();
|
||||
if (!cacheComplete) {
|
||||
if (section->isPartial()) {
|
||||
LOG_DBG("ERS", "Partial cache found (%d pages), resuming build...", section->pageCount);
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache not found, building...");
|
||||
}
|
||||
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
|
||||
auto buildSection = [&]() {
|
||||
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
|
||||
};
|
||||
|
||||
bool built = buildSection();
|
||||
if (!built && SETTINGS.bluetoothEnabled) {
|
||||
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
|
||||
// Free the BLE stack, build, then restore it. The chapter is cached afterwards, so
|
||||
// this recovery runs at most once per uncached chapter; BT reconnects in a few s.
|
||||
// Building a section needs a large contiguous inflate (deflate) window that the
|
||||
// resident NimBLE stack fragments out of existence (~16 KB max block with BT on).
|
||||
// On build failure with BT enabled: free the BLE stack, retry the build, then
|
||||
// restore it. The inflated HTML is cached after a successful build, so this
|
||||
// recovery runs at most once per uncached chapter; BT reconnects in a few s.
|
||||
const auto retryWithBleFreed = [&](auto&& buildFn) {
|
||||
LOG_INF("ERS", "Section build failed with Bluetooth on; freeing BLE RAM and retrying");
|
||||
bleinput::setLifecyclePaused(true);
|
||||
bleinput::stop();
|
||||
built = buildSection();
|
||||
const bool built = buildFn();
|
||||
const bool bleOk = bleinput::ensureStarted();
|
||||
bleinput::setLifecyclePaused(false);
|
||||
LOG_INF("ERS", "BLE restart after build: begin=%d", bleOk);
|
||||
@@ -882,36 +985,127 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
// without ghosting the grayscale page.
|
||||
bleinput::showConnectingUntilLinked(renderer, mappedInput);
|
||||
requestGhostCleanup();
|
||||
}
|
||||
if (!built) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
return built;
|
||||
};
|
||||
|
||||
// Jumps that need the final pagination or the anchor map -- explicit page jumps,
|
||||
// fragment anchors, percent jumps, and cross-setting progress repositioning -- can't
|
||||
// resolve their landing page until the whole chapter is laid out, so they take the full
|
||||
// (blocking) build with the indexing popup. Everything else -- plain forward reads, resume,
|
||||
// and explicit page jumps -- only needs a specific page, so it builds incrementally to that
|
||||
// page and finishes the rest in loop(). The settings-change reposition (cachedChapterTotal*)
|
||||
// is NOT a full-build trigger: it's deferred to applyDeferredReposition() once the real page
|
||||
// count is known, so it never blocks the first page.
|
||||
// Only a percent jump truly needs the whole chapter up front (percent -> page needs the final
|
||||
// page count). Anchor jumps (TOC / chapter select / footnotes) resolve incrementally below --
|
||||
// the anchor is recorded as its page is laid out, so a chapter-top anchor lands on page 0
|
||||
// without indexing the whole chapter.
|
||||
const bool needsFullBuild = pendingPercentJump;
|
||||
if (needsFullBuild) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
|
||||
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
const auto popupFn = [this]() { GUI.drawPopup(renderer, tr(STR_INDEXING)); };
|
||||
const auto buildSection = [&]() {
|
||||
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled, popupFn);
|
||||
};
|
||||
bool built = buildSection();
|
||||
if (!built && SETTINGS.bluetoothEnabled) {
|
||||
built = retryWithBleFreed(buildSection);
|
||||
}
|
||||
if (!built) {
|
||||
LOG_ERR("ERS", "Failed to persist page data to SD");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
|
||||
// indexing popup up front only when the build will actually be slow: a large spine (its
|
||||
// whole HTML must be inflated before page 1 can lay out -- the giant single-spine case), or
|
||||
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
|
||||
// build in a blink and stay popup-free.
|
||||
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
|
||||
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
|
||||
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
|
||||
// Popup only when the build will actually be slow: a big spine whose HTML still needs
|
||||
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
|
||||
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
|
||||
// A partial cache that already covers the target page shows it instantly: never popup.
|
||||
const bool willInflate = !section->hasHtmlCache();
|
||||
const bool anchorJump = !pendingAnchor.empty();
|
||||
bool showPopup;
|
||||
if (anchorJump) {
|
||||
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
|
||||
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
|
||||
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
|
||||
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
|
||||
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
|
||||
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
|
||||
} else {
|
||||
const bool targetAvailable = target < static_cast<int>(section->pageCount);
|
||||
showPopup = !targetAvailable &&
|
||||
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
|
||||
}
|
||||
if (showPopup) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
|
||||
pagesUntilFullRefresh = 1;
|
||||
}
|
||||
// startBuild does the zip inflate (the big contiguous allocation), so it gets
|
||||
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
|
||||
// retry safe.
|
||||
const auto beginBuild = [&]() {
|
||||
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
|
||||
};
|
||||
bool started = beginBuild();
|
||||
if (!started && SETTINGS.bluetoothEnabled) {
|
||||
started = retryWithBleFreed(beginBuild);
|
||||
}
|
||||
if (!started) {
|
||||
LOG_ERR("ERS", "Failed to start section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
while (!section->isBuildComplete() &&
|
||||
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
|
||||
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
|
||||
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
|
||||
// Otherwise: build until the target page exists. loop() builds the rest behind it.
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LOG_DBG("ERS", "Cache found, skipping build...");
|
||||
}
|
||||
|
||||
if (pendingPageJump.has_value()) {
|
||||
if (*pendingPageJump >= section->pageCount && section->pageCount > 0) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
} else {
|
||||
section->currentPage = *pendingPageJump;
|
||||
}
|
||||
section->currentPage = *pendingPageJump;
|
||||
pendingPageJump.reset();
|
||||
} else {
|
||||
section->currentPage = nextPageNumber;
|
||||
if (section->currentPage < 0) {
|
||||
section->currentPage = 0;
|
||||
} else if (section->currentPage >= section->pageCount && section->pageCount > 0) {
|
||||
LOG_DBG("ERS", "Clamping cached page %d to %d", section->currentPage, section->pageCount - 1);
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pendingAnchor.empty()) {
|
||||
if (const auto page = section->getPageForAnchor(pendingAnchor)) {
|
||||
// Resolve from the pages laid out so far and/or the on-disk map (finalized or partial).
|
||||
const auto page = section->findAnchor(pendingAnchor);
|
||||
if (page) {
|
||||
section->currentPage = *page;
|
||||
LOG_DBG("ERS", "Resolved anchor '%s' to page %d", pendingAnchor.c_str(), *page);
|
||||
} else {
|
||||
@@ -920,17 +1114,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
pendingAnchor.clear();
|
||||
}
|
||||
|
||||
// handles changes in reader settings and reset to approximate position based on cached progress
|
||||
if (cachedChapterTotalPageCount > 0) {
|
||||
// only goes to relative position if spine index matches cached value
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * section->pageCount);
|
||||
section->currentPage = newPage;
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
|
||||
}
|
||||
|
||||
if (pendingPercentJump && section->pageCount > 0) {
|
||||
// Apply the pending percent jump now that we know the new section's page count.
|
||||
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
|
||||
@@ -942,6 +1125,57 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extend the build to the requested page if needed (for partials and in-progress builds).
|
||||
// This runs every render, so it covers both the first page and any forward turn that gets
|
||||
// ahead of the background builder; pages already built do no work here.
|
||||
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
// Start a build to extend a partial toward the requested page.
|
||||
if (!section->isBuilding() &&
|
||||
!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight,
|
||||
SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, SETTINGS.imageRendering,
|
||||
SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed to start partial extension build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
// Extend until either the target page exists or the build completes.
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// For an in-progress incremental build, make sure the page we're about to show has been laid out.
|
||||
if (section->isBuilding()) {
|
||||
while (!section->isBuildComplete() && section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
|
||||
LOG_ERR("ERS", "Failed during incremental section build");
|
||||
section.reset();
|
||||
showPendingSyncSaveError();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The requested page is now as built as it will get. If it still lands past the end,
|
||||
// clamp to the last real page: the UINT16_MAX "last page" sentinel from backward chapter
|
||||
// navigation, an explicit jump beyond a finished chapter, or a stale saved position.
|
||||
// Guarded on !isBuilding() because a still-building section's pageCount is only the current
|
||||
// watermark (not the final count) and has already been driven far enough by the loops above.
|
||||
if (!section->isBuilding() && section->pageCount > 0 &&
|
||||
section->currentPage >= static_cast<int>(section->pageCount)) {
|
||||
section->currentPage = section->pageCount - 1;
|
||||
}
|
||||
|
||||
// Apply a deferred settings-change reposition now that the real page count is known (a no-op for
|
||||
// a plain resume / unchanged pagination). If still building, this defers to loop() on completion.
|
||||
applyDeferredReposition();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
if (section->pageCount == 0) {
|
||||
@@ -967,9 +1201,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
updateBookmarkFlag();
|
||||
|
||||
{
|
||||
auto p = section->loadPageFromSectionFile();
|
||||
// Unified page read: the in-progress build's in-RAM table if it has reached the page,
|
||||
// otherwise the on-disk file (finalized section, or a partial from a previous session).
|
||||
auto p = section->loadPage(section->currentPage);
|
||||
if (!p) {
|
||||
LOG_ERR("ERS", "Failed to load page from SD - clearing section cache");
|
||||
// Abandon (not suspend) any active build BEFORE clearing: clearCache deletes the files,
|
||||
// and the destructor's suspend would otherwise commit tables into a deleted handle.
|
||||
section->abandonBuild();
|
||||
section->clearCache();
|
||||
section.reset();
|
||||
requestUpdate(); // Try again after clearing cache
|
||||
@@ -986,8 +1225,7 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
|
||||
}
|
||||
silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
|
||||
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
|
||||
|
||||
showPendingSyncSaveError();
|
||||
|
||||
@@ -1001,36 +1239,28 @@ void EpubReaderActivity::render(RenderLock&& lock) {
|
||||
}
|
||||
}
|
||||
|
||||
void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) {
|
||||
if (!epub || !section || section->pageCount < 2) {
|
||||
return;
|
||||
bool EpubReaderActivity::applyDeferredReposition() {
|
||||
if (cachedChapterTotalPageCount == 0 || !section || section->isBuilding()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the next chapter cache while the penultimate page is on screen.
|
||||
if (section->currentPage != section->pageCount - 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int nextSpineIndex = currentSpineIndex + 1;
|
||||
if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Section nextSection(epub, nextSpineIndex, renderer);
|
||||
if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex);
|
||||
if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
|
||||
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
|
||||
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
|
||||
LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex);
|
||||
bool changed = false;
|
||||
// Only remap when the chapter actually re-paginated (e.g. after a settings change). A plain
|
||||
// resume has identical pagination, so section->pageCount == cachedChapterTotalPageCount and
|
||||
// nothing moves.
|
||||
if (currentSpineIndex == cachedSpineIndex && section->pageCount != cachedChapterTotalPageCount) {
|
||||
const float progress = static_cast<float>(section->currentPage) / static_cast<float>(cachedChapterTotalPageCount);
|
||||
int newPage = static_cast<int>(progress * static_cast<float>(section->pageCount));
|
||||
if (newPage < 0) newPage = 0;
|
||||
if (section->pageCount > 0 && newPage >= static_cast<int>(section->pageCount)) {
|
||||
newPage = section->pageCount - 1;
|
||||
}
|
||||
if (newPage != section->currentPage) {
|
||||
section->currentPage = newPage;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
cachedChapterTotalPageCount = 0; // consumed; don't read cached progress again
|
||||
return changed;
|
||||
}
|
||||
|
||||
bool EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
|
||||
@@ -1203,9 +1433,10 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
||||
}
|
||||
|
||||
void EpubReaderActivity::renderStatusBar() const {
|
||||
// Calculate progress in book
|
||||
// Calculate progress in book. Use the estimated total while a giant spine is still building so
|
||||
// "page X of Y" and the progress bar don't read off the small build watermark.
|
||||
const int currentPage = section->currentPage + 1;
|
||||
const float pageCount = section->pageCount;
|
||||
const float pageCount = section->estimatedTotalPages();
|
||||
const float sectionChapterProg = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) : 0;
|
||||
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||
|
||||
@@ -1236,7 +1467,8 @@ void EpubReaderActivity::renderStatusBar() const {
|
||||
title = epub->getTitle();
|
||||
}
|
||||
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked);
|
||||
GUI.drawStatusBar(renderer, bookProgress, currentPage, pageCount, title, 0, textYOffset, true, currentPageBookmarked,
|
||||
section->isBuilding());
|
||||
}
|
||||
|
||||
void EpubReaderActivity::navigateToHref(const std::string& hrefStr, const bool savePosition) {
|
||||
@@ -1327,7 +1559,7 @@ void EpubReaderActivity::addBookmark() {
|
||||
int pageCount;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
pageCount = section->pageCount;
|
||||
pageCount = section->estimatedTotalPages();
|
||||
currentPage = section->currentPage;
|
||||
}
|
||||
|
||||
@@ -1335,10 +1567,12 @@ void EpubReaderActivity::addBookmark() {
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, currentPage, pageCount);
|
||||
|
||||
const size_t bookmarkCountBeforeToggle = cachedBookmarks.size();
|
||||
cachedBookmarks.erase(
|
||||
std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
|
||||
[&](const BookmarkEntry& b) { return bookmarkMatchesProgress(b, progress, pageRange); }),
|
||||
cachedBookmarks.end());
|
||||
cachedBookmarks.erase(std::remove_if(cachedBookmarks.begin(), cachedBookmarks.end(),
|
||||
[&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, currentPage, pageCount,
|
||||
pageRange);
|
||||
}),
|
||||
cachedBookmarks.end());
|
||||
if (cachedBookmarks.size() != bookmarkCountBeforeToggle) {
|
||||
bookmarkRemoved = true;
|
||||
currentPageBookmarked = false;
|
||||
@@ -1374,11 +1608,10 @@ void EpubReaderActivity::updateBookmarkFlag() {
|
||||
currentPageBookmarked = false;
|
||||
return;
|
||||
}
|
||||
SavedProgressPosition progress = ProgressMapper::toSavedProgress(epub, getCurrentPosition());
|
||||
const ProgressRange pageRange =
|
||||
getPageProgressRange(epub, currentSpineIndex, section->currentPage, section->pageCount);
|
||||
const int pageCount = section->estimatedTotalPages();
|
||||
const ProgressRange pageRange = getPageProgressRange(epub, currentSpineIndex, section->currentPage, pageCount);
|
||||
currentPageBookmarked = std::any_of(cachedBookmarks.begin(), cachedBookmarks.end(), [&](const BookmarkEntry& b) {
|
||||
return bookmarkMatchesProgress(b, progress, pageRange);
|
||||
return bookmarkMatchesProgress(b, currentSpineIndex, section->currentPage, pageCount, pageRange);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1391,9 +1624,9 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
}
|
||||
if (section) {
|
||||
info.currentPage = section->currentPage + 1;
|
||||
info.totalPages = section->pageCount;
|
||||
if (epub && epub->getBookSize() > 0 && section->pageCount > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(section->pageCount);
|
||||
info.totalPages = section->estimatedTotalPages();
|
||||
if (epub && epub->getBookSize() > 0 && info.totalPages > 0) {
|
||||
const float chapterProgress = static_cast<float>(section->currentPage) / static_cast<float>(info.totalPages);
|
||||
int pct = static_cast<int>(epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f + 0.5f);
|
||||
if (pct < 0) pct = 0;
|
||||
if (pct > 100) pct = 100;
|
||||
@@ -1405,7 +1638,7 @@ ScreenshotInfo EpubReaderActivity::getScreenshotInfo() const {
|
||||
|
||||
CrossPointPosition EpubReaderActivity::getCurrentPosition() const {
|
||||
const int currentPage = section ? section->currentPage : nextPageNumber;
|
||||
const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount;
|
||||
const int totalPages = section ? section->estimatedTotalPages() : cachedChapterTotalPageCount;
|
||||
std::optional<uint16_t> paragraphIndex;
|
||||
if (section && currentPage >= 0 && currentPage < section->pageCount) {
|
||||
const uint16_t paragraphPage =
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <optional>
|
||||
|
||||
#include "BookmarkEntry.h"
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "activities/Activity.h"
|
||||
@@ -45,6 +46,8 @@ class EpubReaderActivity final : public Activity {
|
||||
// Set when the reader is left at end-of-book and SETTINGS.moveFinishedToReadFolder is on.
|
||||
// Consumed in onExit() to relocate the finished book into /Read/.
|
||||
bool pendingReadFolderMove = false;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
// Footnote support
|
||||
std::vector<FootnoteEntry> currentPageFootnotes;
|
||||
@@ -59,11 +62,37 @@ class EpubReaderActivity final : public Activity {
|
||||
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
|
||||
int orientedMarginBottom, int orientedMarginLeft);
|
||||
void renderStatusBar() const;
|
||||
void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight);
|
||||
// Pages laid out per incremental-build pump: on the render path (catching up to the page
|
||||
// being shown) and per loop() tick (background build of a large chapter). Kept small so a
|
||||
// background build chunk never noticeably delays input or a pending render.
|
||||
static constexpr int BUILD_PAGES_PER_CHUNK = 8;
|
||||
static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2;
|
||||
// How many pages to keep laid out ahead of the reader for a still-building section. A page
|
||||
// turn is ~1s on e-ink and a page builds in ~30ms, so the reader can't out-click the builder
|
||||
// -- a tiny buffer is enough. The background build stops once the watermark is this far
|
||||
// ahead and resumes as the reader advances; building unbounded instead locked up input by
|
||||
// monopolizing the RenderLock. A giant single-spine book therefore never finalizes its .bin
|
||||
// in one sitting -- instant reopen comes from Section::suspendBuild() persisting the pages
|
||||
// already laid out as a partial file on exit/sleep.
|
||||
static constexpr int BUILD_WINDOW_AHEAD = 5;
|
||||
// Show the indexing popup when an initial build must lay out more than this many pages up front
|
||||
// (a deep resume/jump into a not-yet-built section), so it isn't a silent wait. Kept independent
|
||||
// of the small look-ahead window so ordinary landings stay popup-free.
|
||||
static constexpr int BUILD_POPUP_PAGE_THRESHOLD = 20;
|
||||
// Also show the popup when first building a spine larger than this (uncompressed bytes): its
|
||||
// whole HTML must be inflated before page 1 can lay out (the giant single-spine case), which is
|
||||
// a multi-second wait. Normal chapters are well under this and stay popup-free.
|
||||
static constexpr size_t BUILD_POPUP_BYTE_THRESHOLD = 96 * 1024;
|
||||
// Remap the cached relative reading position once the section's real page count is known
|
||||
// (used after a settings change re-paginates a chapter). Returns true if currentPage moved.
|
||||
// No-op while the section is still building or when the pagination is unchanged (plain resume).
|
||||
bool applyDeferredReposition();
|
||||
bool saveProgress(int spineIndex, int currentPage, int pageCount);
|
||||
// Jump to a percentage of the book (0-100), mapping it to spine and page.
|
||||
void jumpToPercent(int percent);
|
||||
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
|
||||
// Opens the reader menu for the current position (short-press Confirm)
|
||||
void openReaderMenu();
|
||||
// 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();
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ProgressMapper.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -108,8 +107,17 @@ void EpubReaderBookmarksActivity::loop() {
|
||||
return;
|
||||
}
|
||||
auto bookmark = bookmarks.at(selectorIndex);
|
||||
CrossPointPosition pos = ProgressMapper::toCrossPoint(epub, {bookmark.xpath, bookmark.percentage}, renderer);
|
||||
setResult(ProgressChangeResult{pos.spineIndex, pos.pageNumber});
|
||||
ProgressChangeResult result{};
|
||||
result.xpath = bookmark.xpath;
|
||||
result.percentage = bookmark.percentage;
|
||||
result.hasSavedProgress = true;
|
||||
if (bookmark.computedChapterPageCount > 0 && bookmark.computedChapterProgress < bookmark.computedChapterPageCount &&
|
||||
bookmark.computedSpineIndex < epub->getSpineItemsCount()) {
|
||||
result.spineIndex = bookmark.computedSpineIndex;
|
||||
result.page = bookmark.computedChapterProgress;
|
||||
result.totalPages = bookmark.computedChapterPageCount;
|
||||
}
|
||||
setResult(std::move(result));
|
||||
finish();
|
||||
return;
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
|
||||
@@ -52,6 +52,8 @@ void EpubReaderMenuActivity::onEnter() {
|
||||
void EpubReaderMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderMenuActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
// Handle navigation
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
@@ -66,14 +68,21 @@ void EpubReaderMenuActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const auto selectedAction = menuItems[selectedIndex].action;
|
||||
if (selectedAction == MenuAction::ROTATE_SCREEN) {
|
||||
// Cycle orientation preview locally; actual rotation happens on menu exit.
|
||||
pendingOrientation = (pendingOrientation + 1) % orientationLabels.size();
|
||||
optionPopup.show(StrId::STR_ORIENTATION, orientationLabels.data(), static_cast<int>(orientationLabels.size()),
|
||||
pendingOrientation, [this](int idx) {
|
||||
pendingOrientation = idx;
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAction == MenuAction::AUTO_PAGE_TURN) {
|
||||
selectedPageTurnOption = (selectedPageTurnOption + 1) % pageTurnLabels.size();
|
||||
optionPopup.show(I18N.get(StrId::STR_AUTO_TURN_PAGES_PER_MIN), pageTurnLabels.data(),
|
||||
static_cast<int>(pageTurnLabels.size()), selectedPageTurnOption, [this](int idx) {
|
||||
selectedPageTurnOption = idx;
|
||||
requestUpdate();
|
||||
});
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
@@ -102,6 +111,8 @@ void EpubReaderMenuActivity::loop() {
|
||||
}
|
||||
|
||||
void EpubReaderMenuActivity::render(RenderLock&&) {
|
||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderMenuActivity final : public Activity {
|
||||
@@ -50,6 +51,7 @@ class EpubReaderMenuActivity final : public Activity {
|
||||
int selectedIndex = 0;
|
||||
|
||||
ButtonNavigator buttonNavigator;
|
||||
OptionPopup optionPopup;
|
||||
std::string title = "Reader Menu";
|
||||
uint8_t pendingOrientation = 0;
|
||||
uint8_t selectedPageTurnOption = 0;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#include "EpubReaderPercentSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
@@ -51,8 +54,14 @@ void EpubReaderPercentSelectionActivity::loop() {
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustPercent(-kSmallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustPercent(kSmallStep); });
|
||||
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustPercent(kLargeStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustPercent(-kLargeStep); });
|
||||
// On X3 the side buttons sit on the left/right edges of the screen rather than as a vertical up/down
|
||||
// rocker (X4), so BTN_UP is physically the left button and BTN_DOWN the right one. Flip the large-step
|
||||
// direction there so the left button decreases and the right button increases, matching the layout.
|
||||
const int upDelta = gpio.deviceIsX3() ? -kLargeStep : kLargeStep;
|
||||
const int downDelta = gpio.deviceIsX3() ? kLargeStep : -kLargeStep;
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this, upDelta] { adjustPercent(upDelta); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down},
|
||||
[this, downDelta] { adjustPercent(downDelta); });
|
||||
}
|
||||
|
||||
void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
||||
@@ -89,8 +98,13 @@ void EpubReaderPercentSelectionActivity::render(RenderLock&&) {
|
||||
const int knobX = barX + 2 + fillWidth - 2;
|
||||
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
||||
|
||||
// Hint text for step sizes.
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, tr(STR_PERCENT_STEP_HINT), true);
|
||||
// Two-line step hint built from separate label + value strings (front buttons = fine step, side
|
||||
// buttons = coarse step), so the layout doesn't depend on a separator hidden in translated text.
|
||||
char line[64];
|
||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_FRONT), kSmallStep);
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 30, line, true);
|
||||
snprintf(line, sizeof(line), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_SIDE), kLargeStep);
|
||||
UITheme::drawCenteredText(renderer, screen, SMALL_FONT_ID, barY + 52, line, true);
|
||||
|
||||
// Button hints follow the current front button layout.
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Memory.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
@@ -14,6 +15,7 @@
|
||||
#include "XtcReaderActivity.h"
|
||||
#include "activities/util/BmpViewerActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
bool ReaderActivity::isXtcFile(const std::string& path) { return FsHelpers::hasXtcExtension(path); }
|
||||
|
||||
@@ -35,6 +37,12 @@ std::unique_ptr<Epub> ReaderActivity::loadEpub(const std::string& path) {
|
||||
LOG_ERR("READER", "Failed to allocate EPUB object");
|
||||
return nullptr;
|
||||
}
|
||||
// First open: building the spine/TOC index (book.bin) takes a couple of seconds. Show the
|
||||
// indexing popup so it isn't a silent wait on the home screen. The cachePath/hash is known at
|
||||
// construction, so this check is valid before load(); a cached open loads in a blink -> no popup.
|
||||
if (!Storage.exists((epub->getCachePath() + "/book.bin").c_str())) {
|
||||
GUI.drawPopup(renderer, tr(STR_INDEXING));
|
||||
}
|
||||
if (epub->load(true, SETTINGS.embeddedStyle == 0)) {
|
||||
return epub;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ class Txt;
|
||||
class ReaderActivity final : public Activity {
|
||||
std::string initialBookPath;
|
||||
std::string currentBookPath; // Track current book path for navigation
|
||||
static std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
// Non-static (unlike the other loaders): draws the first-open indexing popup, which needs the renderer.
|
||||
std::unique_ptr<Epub> loadEpub(const std::string& path);
|
||||
static std::unique_ptr<Xtc> loadXtc(const std::string& path);
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
static bool isXtcFile(const std::string& path);
|
||||
|
||||
@@ -53,18 +53,52 @@ void XtcReaderActivity::onExit() {
|
||||
xtc.reset();
|
||||
}
|
||||
|
||||
void XtcReaderActivity::openChapterSelection() {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
startActivityForResult(std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
currentPage = std::get<PageResult>(result.data).page;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void XtcReaderActivity::loop() {
|
||||
if (!xtc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool atEndOfBook = currentPage >= xtc->getPageCount();
|
||||
|
||||
// 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
|
||||
// block.
|
||||
if (atEndOfBook && endOfBookOptions.menuActive()) {
|
||||
std::string openPath;
|
||||
switch (endOfBookOptions.handleMenuInput(mappedInput, &openPath)) {
|
||||
case EndOfBookOptions::Action::OpenBook:
|
||||
activityManager.goToReader(openPath);
|
||||
return;
|
||||
case EndOfBookOptions::Action::GoHome:
|
||||
onGoHome();
|
||||
return;
|
||||
case EndOfBookOptions::Action::LastPage:
|
||||
currentPage = xtc->getPageCount() > 0 ? xtc->getPageCount() - 1 : 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::Redraw:
|
||||
requestUpdate();
|
||||
return;
|
||||
case EndOfBookOptions::Action::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter chapter selection activity
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (xtc && xtc->hasChapters() && !xtc->getChapters().empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<XtcReaderChapterSelectionActivity>(renderer, mappedInput, xtc, currentPage),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
currentPage = std::get<PageResult>(result.data).page;
|
||||
}
|
||||
});
|
||||
}
|
||||
openChapterSelection();
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) goes to file selection
|
||||
@@ -85,8 +119,14 @@ void XtcReaderActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// At end of the book, forward button goes home and back button returns to last page
|
||||
// At end of the book with no suggestion menu, forward button goes home and back
|
||||
// button returns to last page
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
if (endOfBookOptions.menuActive()) {
|
||||
// Selection movement was handled above; absorb leftover page-turn triggers so
|
||||
// e.g. "previous" at the top of the list doesn't jump back into the book
|
||||
return;
|
||||
}
|
||||
if (nextTriggered) {
|
||||
onGoHome();
|
||||
} else {
|
||||
@@ -123,9 +163,11 @@ void XtcReaderActivity::render(RenderLock&&) {
|
||||
|
||||
// Bounds check
|
||||
if (currentPage >= xtc->getPageCount()) {
|
||||
// Show end of book screen
|
||||
// Show end of book screen. Sole load site: runs on the render task (serialized by
|
||||
// RenderLock); the main task only reads the suggestions once the flag is published.
|
||||
endOfBookOptions.loadOnce(xtc->getPath());
|
||||
renderer.clearScreen();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_END_OF_BOOK), true, EpdFontFamily::BOLD);
|
||||
endOfBookOptions.render(renderer, mappedInput);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "EndOfBookOptions.h"
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class XtcReaderActivity final : public Activity {
|
||||
@@ -19,6 +20,8 @@ class XtcReaderActivity final : public Activity {
|
||||
|
||||
uint32_t currentPage = 0;
|
||||
int pagesUntilFullRefresh = 0;
|
||||
// Next-book suggestion menu for the End-of-Book screen
|
||||
EndOfBookOptions endOfBookOptions;
|
||||
|
||||
enum class StatusBarOverlayPosition { Bottom, Top };
|
||||
struct StatusBarInfo {
|
||||
@@ -28,6 +31,8 @@ class XtcReaderActivity final : public Activity {
|
||||
};
|
||||
|
||||
void renderPage();
|
||||
// Opens chapter selection when the book has chapters (short-press Confirm); no-op otherwise
|
||||
void openChapterSelection();
|
||||
void renderStatusBarOverlay(StatusBarOverlayPosition position) const;
|
||||
StatusBarInfo getStatusBarInfo() const;
|
||||
void saveProgress() const;
|
||||
|
||||
@@ -115,6 +115,8 @@ void SettingsActivity::onExit() {
|
||||
}
|
||||
|
||||
void SettingsActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
bool hasChangedCategory = false;
|
||||
|
||||
// Handle actions with early return
|
||||
@@ -205,6 +207,18 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
SETTINGS.*(setting.valuePtr) = !currentValue;
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
if (setting.enumValues.size() > 2) {
|
||||
const auto valuePtr = setting.valuePtr;
|
||||
optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast<int>(setting.enumValues.size()),
|
||||
currentValue, [this, valuePtr, sleepScreenChanged, quickResumeTimeoutChanged](int idx) {
|
||||
SETTINGS.*valuePtr = idx;
|
||||
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
||||
if (setting.nameId == StrId::STR_FONT_FAMILY) {
|
||||
@@ -220,6 +234,23 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
? static_cast<uint8_t>(setting.enumValues.size())
|
||||
: static_cast<uint8_t>(setting.enumStringValues.size());
|
||||
const uint8_t cur = setting.valueGetter();
|
||||
if (totalValues > 2) {
|
||||
const auto valueSetter = setting.valueSetter;
|
||||
auto onSelect = [this, valueSetter, sleepScreenChanged, quickResumeTimeoutChanged](int idx) {
|
||||
valueSetter(idx);
|
||||
syncQuickResumeTimeoutForSleepScreen(sleepScreenChanged, quickResumeTimeoutChanged);
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
};
|
||||
if (!setting.enumStringValues.empty()) {
|
||||
optionPopup.show(setting.nameId, setting.enumStringValues, cur, std::move(onSelect));
|
||||
} else {
|
||||
optionPopup.show(setting.nameId, setting.enumValues.data(), static_cast<int>(setting.enumValues.size()), cur,
|
||||
std::move(onSelect));
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
setting.valueSetter((cur + 1) % totalValues);
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
@@ -310,10 +341,9 @@ void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChan
|
||||
void SettingsActivity::openSleepTimeoutPicker() {
|
||||
startActivityForResult(
|
||||
std::make_unique<IntervalSelectionActivity>(
|
||||
renderer, mappedInput, "SleepTimeoutInterval", StrId::STR_TIME_TO_SLEEP, StrId::STR_SLEEP_TIMER_STEP_HINT,
|
||||
SETTINGS.sleepTimeoutMinutes, CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES,
|
||||
CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1, 5, StrId::STR_SLEEP_TIMER_VALUE_FORMAT, false, true,
|
||||
StrId::STR_SLEEP_NEVER),
|
||||
renderer, mappedInput, "SleepTimeoutInterval", StrId::STR_TIME_TO_SLEEP, SETTINGS.sleepTimeoutMinutes,
|
||||
CrossPointSettings::MIN_SLEEP_TIMEOUT_MINUTES, CrossPointSettings::MAX_SLEEP_TIMEOUT_MINUTES, 1, 5,
|
||||
StrId::STR_SLEEP_TIMER_VALUE_FORMAT, false, true, StrId::STR_SLEEP_NEVER),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
SETTINGS.sleepTimeoutMinutes = static_cast<uint8_t>(std::get<IntervalResult>(result.data).value);
|
||||
@@ -324,6 +354,8 @@ void SettingsActivity::openSleepTimeoutPicker() {
|
||||
}
|
||||
|
||||
void SettingsActivity::render(RenderLock&&) {
|
||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
@@ -391,6 +423,7 @@ void SettingsActivity::render(RenderLock&&) {
|
||||
: (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP
|
||||
? tr(STR_SELECT)
|
||||
: tr(STR_TOGGLE));
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE, STRING };
|
||||
@@ -160,6 +161,8 @@ class SettingsActivity final : public Activity {
|
||||
bool preserveQuickResumeTimeoutOn = false;
|
||||
bool quickResumeTimeoutAutoEnabled = false;
|
||||
|
||||
OptionPopup optionPopup;
|
||||
|
||||
static constexpr int categoryCount = 4;
|
||||
static const StrId categoryNames[categoryCount];
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ const StrId titleNames[TITLE_ITEMS] = {StrId::STR_BOOK, StrId::STR_CHAPTER, StrI
|
||||
constexpr int XTC_STATUS_BAR_ITEMS = 3;
|
||||
const StrId xtcStatusBarNames[XTC_STATUS_BAR_ITEMS] = {StrId::STR_HIDE, StrId::STR_BOTTOM, StrId::STR_TOP};
|
||||
|
||||
constexpr int STATUS_BAR_CLOCK_ITEMS = 3;
|
||||
const StrId statusBarClockNames[STATUS_BAR_CLOCK_ITEMS] = {StrId::STR_HIDE, StrId::STR_DIR_RIGHT, StrId::STR_DIR_LEFT};
|
||||
|
||||
const int verticalPreviewPadding = 50;
|
||||
const int verticalPreviewTextPadding = 40;
|
||||
} // namespace
|
||||
@@ -112,12 +115,18 @@ void StatusBarSettingsActivity::onEnter() {
|
||||
SETTINGS.clockFormat = 0;
|
||||
}
|
||||
|
||||
if (SETTINGS.statusBarClock >= STATUS_BAR_CLOCK_ITEMS) {
|
||||
SETTINGS.statusBarClock = CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void StatusBarSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void StatusBarSettingsActivity::loop() {
|
||||
if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return;
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
@@ -160,23 +169,37 @@ void StatusBarSettingsActivity::handleSelection() {
|
||||
SETTINGS.statusBarBookProgressPercentage = (SETTINGS.statusBarBookProgressPercentage + 1) % 2;
|
||||
break;
|
||||
case ITEM_PROGRESS_BAR:
|
||||
SETTINGS.statusBarProgressBar = (SETTINGS.statusBarProgressBar + 1) % PROGRESS_BAR_ITEMS;
|
||||
break;
|
||||
optionPopup.show(StrId::STR_PROGRESS_BAR, progressBarNames, PROGRESS_BAR_ITEMS, SETTINGS.statusBarProgressBar,
|
||||
[this](int idx) {
|
||||
SETTINGS.statusBarProgressBar = idx;
|
||||
SETTINGS.saveToFile();
|
||||
});
|
||||
return;
|
||||
case ITEM_PROGRESS_BAR_THICKNESS:
|
||||
SETTINGS.statusBarProgressBarThickness =
|
||||
(SETTINGS.statusBarProgressBarThickness + 1) % PROGRESS_BAR_THICKNESS_ITEMS;
|
||||
break;
|
||||
optionPopup.show(StrId::STR_PROGRESS_BAR_THICKNESS, progressBarThicknessNames, PROGRESS_BAR_THICKNESS_ITEMS,
|
||||
SETTINGS.statusBarProgressBarThickness, [this](int idx) {
|
||||
SETTINGS.statusBarProgressBarThickness = idx;
|
||||
SETTINGS.saveToFile();
|
||||
});
|
||||
return;
|
||||
case ITEM_TITLE:
|
||||
SETTINGS.statusBarTitle = (SETTINGS.statusBarTitle + 1) % TITLE_ITEMS;
|
||||
break;
|
||||
optionPopup.show(StrId::STR_TITLE, titleNames, TITLE_ITEMS, SETTINGS.statusBarTitle, [this](int idx) {
|
||||
SETTINGS.statusBarTitle = idx;
|
||||
SETTINGS.saveToFile();
|
||||
});
|
||||
return;
|
||||
case ITEM_BATTERY:
|
||||
SETTINGS.statusBarBattery = (SETTINGS.statusBarBattery + 1) % 2;
|
||||
break;
|
||||
case ITEM_XTC_STATUS_BAR:
|
||||
SETTINGS.xtcStatusBarMode = (SETTINGS.xtcStatusBarMode + 1) % XTC_STATUS_BAR_ITEMS;
|
||||
break;
|
||||
optionPopup.show(StrId::STR_XTC_STATUS_BAR, xtcStatusBarNames, XTC_STATUS_BAR_ITEMS, SETTINGS.xtcStatusBarMode,
|
||||
[this](int idx) {
|
||||
SETTINGS.xtcStatusBarMode = idx;
|
||||
SETTINGS.saveToFile();
|
||||
});
|
||||
return;
|
||||
case ITEM_CLOCK:
|
||||
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % 2;
|
||||
SETTINGS.statusBarClock = (SETTINGS.statusBarClock + 1) % STATUS_BAR_CLOCK_ITEMS;
|
||||
break;
|
||||
case ITEM_CLOCK_FORMAT:
|
||||
SETTINGS.clockFormat = (SETTINGS.clockFormat + 1) % CLOCK_FORMAT_ITEMS;
|
||||
@@ -195,6 +218,8 @@ void StatusBarSettingsActivity::handleSelection() {
|
||||
}
|
||||
|
||||
void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||
if (optionPopup.processRender(renderer, mappedInput)) return;
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
@@ -225,7 +250,7 @@ void StatusBarSettingsActivity::render(RenderLock&&) {
|
||||
case ITEM_XTC_STATUS_BAR:
|
||||
return I18N.get(xtcStatusBarNames[SETTINGS.xtcStatusBarMode]);
|
||||
case ITEM_CLOCK:
|
||||
return SETTINGS.statusBarClock ? tr(STR_SHOW) : tr(STR_HIDE);
|
||||
return I18N.get(statusBarClockNames[SETTINGS.statusBarClock]);
|
||||
case ITEM_CLOCK_FORMAT: {
|
||||
const uint8_t fmt = SETTINGS.clockFormat < CLOCK_FORMAT_ITEMS ? SETTINGS.clockFormat : 0;
|
||||
return std::string(I18N.get(clockFormatNames[fmt]));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "components/OptionPopup.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// Reader status bar configuration activity
|
||||
@@ -19,6 +20,7 @@ class StatusBarSettingsActivity final : public Activity {
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
OptionPopup optionPopup;
|
||||
|
||||
int selectedIndex = 0;
|
||||
// Decided in onEnter() based on halClock.isAvailable() so clock entries are hidden on X4.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "IntervalSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalGPIO.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -25,6 +26,18 @@ void IntervalSelectionActivity::adjustValue(const int delta) {
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void IntervalSelectionActivity::drawStepHintLine(const int y, const StrId labelId, const int step) {
|
||||
char stepText[24];
|
||||
if (valueFormatId != StrId::STR_NONE_OPT) {
|
||||
snprintf(stepText, sizeof(stepText), I18N.get(valueFormatId), static_cast<unsigned int>(step));
|
||||
} else {
|
||||
snprintf(stepText, sizeof(stepText), "%d", step);
|
||||
}
|
||||
char line[64];
|
||||
snprintf(line, sizeof(line), "%s %s", I18N.get(labelId), stepText);
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, y, line, true);
|
||||
}
|
||||
|
||||
void IntervalSelectionActivity::loop() {
|
||||
if (ignoreConfirmRelease) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
@@ -52,8 +65,15 @@ void IntervalSelectionActivity::loop() {
|
||||
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { adjustValue(-smallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { adjustValue(smallStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { adjustValue(largeStep); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { adjustValue(-largeStep); });
|
||||
|
||||
// On X3 the side buttons sit on the left/right edges of the screen rather than as a vertical up/down
|
||||
// rocker (X4), so BTN_UP is physically the left button and BTN_DOWN the right one. Flip the large-step
|
||||
// direction there so the left button decreases and the right button increases, matching the layout.
|
||||
const int upDelta = gpio.deviceIsX3() ? -largeStep : largeStep;
|
||||
const int downDelta = gpio.deviceIsX3() ? largeStep : -largeStep;
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this, upDelta] { adjustValue(upDelta); });
|
||||
buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down},
|
||||
[this, downDelta] { adjustValue(downDelta); });
|
||||
}
|
||||
|
||||
void IntervalSelectionActivity::render(RenderLock&&) {
|
||||
@@ -88,7 +108,11 @@ void IntervalSelectionActivity::render(RenderLock&&) {
|
||||
const int knobX = std::max(barX + 2, barX + 2 + fillWidth - 2);
|
||||
renderer.fillRect(knobX, barY - 4, 4, barHeight + 8, true);
|
||||
|
||||
renderer.drawCenteredText(SMALL_FONT_ID, barY + 30, I18N.get(stepHintId), true);
|
||||
// Two-line step hint: front buttons do the small step, side buttons the large step. Built from
|
||||
// separate label + value strings (rather than splitting one localized sentence) so the layout
|
||||
// doesn't depend on translators preserving a hidden separator.
|
||||
drawStepHintLine(barY + 30, StrId::STR_STEP_HINT_FRONT, smallStep);
|
||||
drawStepHintLine(barY + 52, StrId::STR_STEP_HINT_SIDE, largeStep);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
@@ -11,13 +11,12 @@ class GfxRenderer;
|
||||
class IntervalSelectionActivity final : public Activity {
|
||||
public:
|
||||
explicit IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* activityName,
|
||||
StrId titleId, StrId stepHintId, int initialValue, int minValue, int maxValue,
|
||||
int smallStep, int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT,
|
||||
StrId titleId, int initialValue, int minValue, int maxValue, int smallStep,
|
||||
int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT,
|
||||
bool readerActivity = false, bool ignoreInitialConfirmRelease = false,
|
||||
StrId maxBoundaryLabelId = StrId::STR_NONE_OPT)
|
||||
: Activity(activityName, renderer, mappedInput),
|
||||
titleId(titleId),
|
||||
stepHintId(stepHintId),
|
||||
valueFormatId(valueFormatId),
|
||||
maxBoundaryLabelId(maxBoundaryLabelId),
|
||||
value(initialValue),
|
||||
@@ -35,7 +34,6 @@ class IntervalSelectionActivity final : public Activity {
|
||||
|
||||
private:
|
||||
StrId titleId;
|
||||
StrId stepHintId;
|
||||
StrId valueFormatId;
|
||||
StrId maxBoundaryLabelId;
|
||||
int value;
|
||||
@@ -49,4 +47,5 @@ class IntervalSelectionActivity final : public Activity {
|
||||
|
||||
void adjustValue(int delta);
|
||||
int clampedValue(int candidate) const;
|
||||
void drawStepHintLine(int y, StrId labelId, int step);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
#include <I18n.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "GfxRenderer.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
|
||||
class OptionPopup {
|
||||
public:
|
||||
void show(StrId titleId, const StrId* optionIds, int optionCount, int currentIndex,
|
||||
std::function<void(int)> onSelect) {
|
||||
title = I18N.get(titleId);
|
||||
ownedStrings.resize(optionCount);
|
||||
for (int i = 0; i < optionCount; i++) {
|
||||
ownedStrings[i] = I18N.get(optionIds[i]);
|
||||
}
|
||||
selectedIndex = currentIndex;
|
||||
onSelectCallback = std::move(onSelect);
|
||||
active = true;
|
||||
}
|
||||
|
||||
void show(const char* titleStr, const char* const* options, int optionCount, int currentIndex,
|
||||
std::function<void(int)> onSelect) {
|
||||
title = titleStr;
|
||||
ownedStrings.resize(optionCount);
|
||||
for (int i = 0; i < optionCount; i++) {
|
||||
ownedStrings[i] = options[i];
|
||||
}
|
||||
selectedIndex = currentIndex;
|
||||
onSelectCallback = std::move(onSelect);
|
||||
active = true;
|
||||
}
|
||||
|
||||
void show(StrId titleId, const std::vector<std::string>& options, int currentIndex,
|
||||
std::function<void(int)> onSelect) {
|
||||
title = I18N.get(titleId);
|
||||
ownedStrings = options;
|
||||
selectedIndex = currentIndex;
|
||||
onSelectCallback = std::move(onSelect);
|
||||
active = true;
|
||||
}
|
||||
|
||||
bool handleInput(MappedInputManager& input, const std::function<void()>& requestUpdate) {
|
||||
if (!active) return false;
|
||||
|
||||
const int count = static_cast<int>(ownedStrings.size());
|
||||
if (input.wasPressed(MappedInputManager::Button::Up) || input.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedIndex = (selectedIndex - 1 + count) % count;
|
||||
requestUpdate();
|
||||
return true;
|
||||
} else if (input.wasPressed(MappedInputManager::Button::Down) ||
|
||||
input.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedIndex = (selectedIndex + 1) % count;
|
||||
requestUpdate();
|
||||
return true;
|
||||
} else if (input.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
active = false;
|
||||
if (onSelectCallback) onSelectCallback(selectedIndex);
|
||||
requestUpdate();
|
||||
return true;
|
||||
} else if (input.wasPressed(MappedInputManager::Button::Back)) {
|
||||
active = false;
|
||||
requestUpdate();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool processRender(GfxRenderer& renderer, const MappedInputManager& input) const {
|
||||
if (!active) return false;
|
||||
const auto popupLabels = input.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, popupLabels.btn1, popupLabels.btn2, popupLabels.btn3, popupLabels.btn4);
|
||||
render(renderer);
|
||||
renderer.displayBuffer();
|
||||
return true;
|
||||
}
|
||||
|
||||
void render(const GfxRenderer& renderer) const {
|
||||
if (!active) return;
|
||||
GUI.drawOptionPopup(renderer, title.c_str(), ownedStrings, selectedIndex);
|
||||
}
|
||||
|
||||
bool isActive() const { return active; }
|
||||
|
||||
private:
|
||||
bool active = false;
|
||||
std::string title;
|
||||
std::vector<std::string> ownedStrings;
|
||||
int selectedIndex = 0;
|
||||
std::function<void(int)> onSelectCallback;
|
||||
};
|
||||
@@ -131,9 +131,10 @@ int UITheme::getStatusBarHeight() {
|
||||
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// Add status bar margin
|
||||
const bool showStatusBar = SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE ||
|
||||
SETTINGS.statusBarBattery;
|
||||
const bool showStatusBar =
|
||||
SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
|
||||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
|
||||
SETTINGS.statusBarClock != CrossPointSettings::STATUS_BAR_CLOCK_MODE::STATUS_BAR_CLOCK_HIDE;
|
||||
const bool showProgressBar =
|
||||
SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS;
|
||||
return (showStatusBar ? (metrics.statusBarVerticalMargin) : 0) +
|
||||
|
||||
@@ -749,7 +749,7 @@ void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
|
||||
|
||||
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
|
||||
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
|
||||
const bool fillMargin, const bool isPageBookmarked) const {
|
||||
const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated) const {
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||
@@ -759,25 +759,32 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
// Draw Progress Text
|
||||
const auto screenHeight = renderer.getScreenHeight();
|
||||
auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4;
|
||||
int progressTextWidth = 0;
|
||||
|
||||
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
|
||||
const int rightClusterX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight;
|
||||
int leftClusterWidth = 0;
|
||||
int rightClusterWidth = 0;
|
||||
|
||||
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
|
||||
// Right aligned text for progress counter
|
||||
char progressStr[32];
|
||||
|
||||
// Prefix the page count with "~" while a still-building spine only yields an estimated total.
|
||||
const char* estimatePrefix = pageCountEstimated ? "~" : "";
|
||||
|
||||
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
|
||||
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress);
|
||||
snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount,
|
||||
bookProgress);
|
||||
} else if (SETTINGS.statusBarBookProgressPercentage) {
|
||||
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
|
||||
} else {
|
||||
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
|
||||
snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount);
|
||||
}
|
||||
|
||||
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
|
||||
renderer.drawText(
|
||||
SMALL_FONT_ID,
|
||||
renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth, textY,
|
||||
progressStr);
|
||||
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
|
||||
renderer.drawText(SMALL_FONT_ID, rightClusterX - progressTextWidth, textY, progressStr);
|
||||
|
||||
rightClusterWidth += progressTextWidth;
|
||||
}
|
||||
|
||||
// Draw Progress Bar
|
||||
@@ -800,39 +807,53 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
renderer.fillRect(barMarginLeft, progressBarY, barWidth, barHeight, true);
|
||||
}
|
||||
|
||||
// Draw Bookmark
|
||||
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
|
||||
const bool showBookmarkIcon = showStatusBarTextLane && isPageBookmarked;
|
||||
const int bookmarkReserveWidth = showBookmarkIcon ? (bookmarkStatusIconWidth + bookmarkStatusIconGap) : 0;
|
||||
if (showBookmarkIcon) {
|
||||
const int bookmarkY = textY + 5;
|
||||
drawBookmarkStatusIcon(renderer, leftClusterX, bookmarkY);
|
||||
}
|
||||
|
||||
// Draw Battery
|
||||
const bool showBatteryPercentage =
|
||||
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
|
||||
int leftClusterWidth = bookmarkReserveWidth;
|
||||
|
||||
if (SETTINGS.statusBarBattery) {
|
||||
GUI.drawBatteryLeft(renderer,
|
||||
Rect{leftClusterX + bookmarkReserveWidth, textY, metrics.batteryWidth, metrics.batteryHeight},
|
||||
Rect{leftClusterX + leftClusterWidth, textY, metrics.batteryWidth, metrics.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
leftClusterWidth += showBatteryPercentage ? 50 : 20;
|
||||
int batteryWidth = metrics.batteryWidth;
|
||||
|
||||
if (showBatteryPercentage) {
|
||||
const uint16_t percentage = powerManager.getBatteryPercentage();
|
||||
// width of icon + spacing + text for layout purposes
|
||||
batteryWidth +=
|
||||
batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, (std::to_string(percentage) + "%").c_str());
|
||||
}
|
||||
|
||||
leftClusterWidth += batteryWidth;
|
||||
}
|
||||
|
||||
// Draw Clock (X3 only — DS3231 RTC)
|
||||
int clockTextWidth = 0;
|
||||
if (SETTINGS.statusBarClock && halClock.isAvailable()) {
|
||||
char timeBuf[9];
|
||||
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
|
||||
clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
|
||||
// Position to the left of the progress text (with a small gap)
|
||||
const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight -
|
||||
progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth;
|
||||
int clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
|
||||
int clockX = 0;
|
||||
// Position to the left or right of the progress text (with a small gap)
|
||||
if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_LEFT) {
|
||||
clockX = leftClusterX + leftClusterWidth + (leftClusterWidth > 0 ? 10 : 0);
|
||||
leftClusterWidth += clockTextWidth + 10;
|
||||
} else if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_RIGHT) {
|
||||
clockX = rightClusterX - rightClusterWidth - (rightClusterWidth > 0 ? 10 : 0) - clockTextWidth;
|
||||
rightClusterWidth += clockTextWidth + 10;
|
||||
}
|
||||
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Bookmark
|
||||
if (showStatusBarTextLane && isPageBookmarked) {
|
||||
const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0;
|
||||
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
|
||||
const int bookmarkY = textY + 5;
|
||||
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
|
||||
leftClusterWidth += bookmarkStatusIconWidth + bookmarkGap;
|
||||
}
|
||||
|
||||
// Draw Title
|
||||
if (!title.empty()) {
|
||||
textY -= textYOffset;
|
||||
@@ -842,8 +863,7 @@ void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, c
|
||||
renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight;
|
||||
|
||||
const int titleMarginLeft = leftClusterWidth + 30;
|
||||
const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0;
|
||||
const int titleMarginRight = progressTextWidth + clockReserve + 30;
|
||||
const int titleMarginRight = rightClusterWidth + 30;
|
||||
|
||||
// Attempt to center title on the screen, but if title is too wide then later we will center it within the
|
||||
// available space.
|
||||
@@ -989,3 +1009,99 @@ void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const ch
|
||||
rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert);
|
||||
}
|
||||
}
|
||||
|
||||
void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector<std::string>& options,
|
||||
int selectedIndex) const {
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID;
|
||||
const EpdFontFamily::Style optionStyle =
|
||||
metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR;
|
||||
|
||||
const int itemSpacing = metrics.optionPopupItemSpacing;
|
||||
const int innerPadding = metrics.optionPopupInnerPadding;
|
||||
const int selectionHPadding = metrics.optionPopupSelectionHPadding;
|
||||
const int selectionVPadding = metrics.optionPopupSelectionVPadding;
|
||||
|
||||
const int optionLineHeight = renderer.getLineHeight(optionFontId);
|
||||
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int rowHeight = optionLineHeight + selectionVPadding * 2;
|
||||
|
||||
int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD);
|
||||
for (const auto& opt : options) {
|
||||
int w = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle);
|
||||
if (w > maxTextWidth) maxTextWidth = w;
|
||||
}
|
||||
|
||||
const int optionCount = static_cast<int>(options.size());
|
||||
const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1);
|
||||
const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10,
|
||||
pageWidth - metrics.optionPopupDialogSideMargin * 2);
|
||||
const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight;
|
||||
const int dialogH = contentHeight + innerPadding * 2;
|
||||
const int dialogX = (pageWidth - dialogW) / 2;
|
||||
const int dialogY = (pageHeight - dialogH) / 2;
|
||||
|
||||
const int frameThickness = metrics.popupFrameThickness;
|
||||
const int frameRadius = metrics.popupCornerRadius;
|
||||
|
||||
if (frameRadius > 0) {
|
||||
renderer.fillRoundedRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2,
|
||||
dialogH + frameThickness * 2, frameRadius + frameThickness, Color::White);
|
||||
renderer.fillRoundedRect(dialogX, dialogY, dialogW, dialogH, frameRadius, Color::Black);
|
||||
renderer.fillRoundedRect(dialogX + frameThickness, dialogY + frameThickness, dialogW - frameThickness * 2,
|
||||
dialogH - frameThickness * 2,
|
||||
frameRadius - frameThickness > 0 ? frameRadius - frameThickness : 0, Color::White);
|
||||
} else {
|
||||
renderer.fillRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2,
|
||||
dialogH + frameThickness * 2, true);
|
||||
renderer.fillRect(dialogX, dialogY, dialogW, dialogH, false);
|
||||
}
|
||||
|
||||
int y = dialogY + innerPadding;
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, y, title, true, EpdFontFamily::BOLD);
|
||||
y += titleLineHeight;
|
||||
|
||||
if (metrics.optionPopupTitleSeparator) {
|
||||
const int sepY = y + metrics.optionPopupTitleGap / 2;
|
||||
renderer.drawLine(dialogX + innerPadding, sepY, dialogX + dialogW - innerPadding, sepY, true);
|
||||
}
|
||||
|
||||
y += metrics.optionPopupTitleGap;
|
||||
|
||||
const int itemRectX = dialogX + innerPadding;
|
||||
const int itemRectW = dialogW - innerPadding * 2;
|
||||
const int selectionRadius = metrics.optionPopupSelectionRadius;
|
||||
|
||||
for (int i = 0; i < optionCount; i++) {
|
||||
const int itemY = y + i * (rowHeight + itemSpacing);
|
||||
const bool selected = (i == selectedIndex);
|
||||
const char* labelText = options[i].c_str();
|
||||
|
||||
if (metrics.optionPopupDrawAllRows || selected) {
|
||||
Color rowColor;
|
||||
if (selected) {
|
||||
rowColor = metrics.optionPopupSelectionLight ? Color::LightGray : Color::Black;
|
||||
} else {
|
||||
rowColor = Color::White;
|
||||
}
|
||||
if (selectionRadius > 0) {
|
||||
renderer.fillRoundedRect(itemRectX, itemY, itemRectW, rowHeight, selectionRadius, rowColor);
|
||||
} else {
|
||||
renderer.fillRect(itemRectX, itemY, itemRectW, rowHeight, rowColor == Color::Black);
|
||||
}
|
||||
}
|
||||
|
||||
const int textW = renderer.getTextWidth(optionFontId, labelText, optionStyle);
|
||||
const int textY = itemY + (rowHeight - optionLineHeight) / 2;
|
||||
const int textX = itemRectX + (itemRectW - textW) / 2;
|
||||
// Unselected items: text is dark (invert=true means draw on white bg).
|
||||
// Selected on dark bg: text must be white (invert=false).
|
||||
// Selected on light bg: text stays dark (invert=true).
|
||||
const bool invertText = selected ? metrics.optionPopupSelectionLight : true;
|
||||
renderer.drawText(optionFontId, textX, textY, labelText, invertText, optionStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,19 @@ struct ThemeMetrics {
|
||||
bool popupProgressFillInverted;
|
||||
bool popupProgressOutlineInverted;
|
||||
|
||||
int optionPopupItemSpacing;
|
||||
int optionPopupInnerPadding;
|
||||
int optionPopupSelectionHPadding;
|
||||
int optionPopupSelectionVPadding;
|
||||
int optionPopupTitleGap;
|
||||
bool optionPopupUseSmallFont;
|
||||
bool optionPopupOptionFontBold;
|
||||
int optionPopupSelectionRadius;
|
||||
bool optionPopupSelectionLight;
|
||||
bool optionPopupDrawAllRows;
|
||||
int optionPopupDialogSideMargin;
|
||||
bool optionPopupTitleSeparator;
|
||||
|
||||
int textFieldHorizontalPadding;
|
||||
int textFieldNormalThickness;
|
||||
int textFieldCursorThickness;
|
||||
@@ -167,6 +180,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
|
||||
.popupProgressClampPercent = false,
|
||||
.popupProgressFillInverted = true,
|
||||
.popupProgressOutlineInverted = true,
|
||||
.optionPopupItemSpacing = 6,
|
||||
.optionPopupInnerPadding = 16,
|
||||
.optionPopupSelectionHPadding = 8,
|
||||
.optionPopupSelectionVPadding = 4,
|
||||
.optionPopupTitleGap = 10,
|
||||
.optionPopupUseSmallFont = true,
|
||||
.optionPopupOptionFontBold = true,
|
||||
.optionPopupSelectionRadius = 0,
|
||||
.optionPopupSelectionLight = false,
|
||||
.optionPopupDrawAllRows = false,
|
||||
.optionPopupDialogSideMargin = 20,
|
||||
.optionPopupTitleSeparator = true,
|
||||
.textFieldHorizontalPadding = 6,
|
||||
.textFieldNormalThickness = 1,
|
||||
.textFieldCursorThickness = 3,
|
||||
@@ -207,10 +232,13 @@ class BaseTheme {
|
||||
const std::function<std::string(int index)>& buttonLabel,
|
||||
const std::function<UIIcon(int index)>& rowIcon) const;
|
||||
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
|
||||
virtual void drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector<std::string>& options,
|
||||
int selectedIndex) const;
|
||||
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
|
||||
void drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount,
|
||||
std::string title, const int paddingBottom = 0, const int textYOffset = 0,
|
||||
const bool fillMargin = true, const bool isPageBookmarked = false) const;
|
||||
const bool fillMargin = true, const bool isPageBookmarked = false,
|
||||
const bool pageCountEstimated = false) const;
|
||||
void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
|
||||
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false,
|
||||
int contentStartX = 0, int contentWidth = 0) const;
|
||||
|
||||
@@ -72,7 +72,7 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
|
||||
tileY + hPaddingInSelection + (Lyra3CoversMetrics::values.homeCoverHeight / 3),
|
||||
tileWidth - 2 * hPaddingInSelection, 2 * Lyra3CoversMetrics::values.homeCoverHeight / 3,
|
||||
true);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
|
||||
const uint8_t* iconBitmap = iconForName(icon, iconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
|
||||
itemY + iconY, iconSize, iconSize);
|
||||
itemY + iconY, iconSize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
|
||||
renderer.fillRect(tileX + hPaddingInSelection,
|
||||
tileY + hPaddingInSelection + (LyraMetrics::values.homeCoverHeight / 3), coverWidth,
|
||||
2 * LyraMetrics::values.homeCoverHeight / 3, true);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
|
||||
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32);
|
||||
}
|
||||
|
||||
coverBufferStored = storeCoverBuffer();
|
||||
@@ -534,7 +534,7 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
|
||||
UIIcon icon = rowIcon(i);
|
||||
const uint8_t* iconBitmap = iconForName(icon, mainMenuIconSize);
|
||||
if (iconBitmap != nullptr) {
|
||||
renderer.drawIcon(iconBitmap, textX, textY + 3, mainMenuIconSize, mainMenuIconSize);
|
||||
renderer.drawIcon(iconBitmap, textX, textY, mainMenuIconSize);
|
||||
textX += mainMenuIconSize + hPaddingInSelection + 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
.popupProgressClampPercent = false,
|
||||
.popupProgressFillInverted = false,
|
||||
.popupProgressOutlineInverted = false,
|
||||
.optionPopupItemSpacing = 8,
|
||||
.optionPopupInnerPadding = 20,
|
||||
.optionPopupSelectionHPadding = 16,
|
||||
.optionPopupSelectionVPadding = 12,
|
||||
.optionPopupTitleGap = 16,
|
||||
.optionPopupUseSmallFont = true,
|
||||
.optionPopupOptionFontBold = false,
|
||||
.optionPopupSelectionRadius = 6,
|
||||
.optionPopupSelectionLight = true,
|
||||
.optionPopupDrawAllRows = false,
|
||||
.optionPopupDialogSideMargin = 20,
|
||||
.optionPopupTitleSeparator = true,
|
||||
.textFieldHorizontalPadding = 6,
|
||||
.textFieldNormalThickness = 1,
|
||||
.textFieldCursorThickness = 3,
|
||||
|
||||
@@ -165,7 +165,7 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
|
||||
// Render empty cover
|
||||
renderer.fillRect(tileX + (tileWidth - coverWidth) / 2, imgY + (RoundedRaffMetrics::values.homeCoverHeight / 3),
|
||||
coverWidth, 2 * RoundedRaffMetrics::values.homeCoverHeight / 3, true);
|
||||
renderer.drawIcon(CoverIcon, tileX + (tileWidth - coverWidth) / 2 + 24, imgY + 24, 32, 32);
|
||||
renderer.drawIcon(CoverIcon, tileX + (tileWidth - coverWidth) / 2 + 24, imgY + 24, 32);
|
||||
renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth,
|
||||
RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius,
|
||||
Color::LightGray);
|
||||
|
||||
@@ -65,6 +65,18 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
|
||||
.popupProgressClampPercent = true,
|
||||
.popupProgressFillInverted = false,
|
||||
.popupProgressOutlineInverted = false,
|
||||
.optionPopupItemSpacing = 6,
|
||||
.optionPopupInnerPadding = 24,
|
||||
.optionPopupSelectionHPadding = 20,
|
||||
.optionPopupSelectionVPadding = 10,
|
||||
.optionPopupTitleGap = 16,
|
||||
.optionPopupUseSmallFont = false,
|
||||
.optionPopupOptionFontBold = true,
|
||||
.optionPopupSelectionRadius = 30,
|
||||
.optionPopupSelectionLight = false,
|
||||
.optionPopupDrawAllRows = true,
|
||||
.optionPopupDialogSideMargin = 20,
|
||||
.optionPopupTitleSeparator = true,
|
||||
.textFieldHorizontalPadding = 8,
|
||||
.textFieldNormalThickness = 2,
|
||||
.textFieldCursorThickness = 3,
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <esp_efuse.h>
|
||||
#include <esp_efuse_table.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "FontInstaller.h"
|
||||
@@ -368,9 +371,27 @@ void CrossPointWebServer::handleStatus() const {
|
||||
doc["uptime"] = millis() / 1000;
|
||||
doc["device"] = gpio.deviceIsX3() ? "X3" : "X4";
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
server->send(200, "application/json", json);
|
||||
char snBuf[33] = {0};
|
||||
bool valid = false;
|
||||
if (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, snBuf, 256) == ESP_OK) {
|
||||
valid = snBuf[0] != '\0' && snBuf[0] != (char)0xFF;
|
||||
for (int i = 0; i < 32 && snBuf[i] != '\0'; i++) {
|
||||
if (!std::isprint(static_cast<unsigned char>(snBuf[i]))) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
doc["serial"] = snBuf;
|
||||
} else {
|
||||
doc["serial"] = "Not found";
|
||||
}
|
||||
|
||||
String response;
|
||||
serializeJson(doc, response);
|
||||
server->send(200, "application/json", response);
|
||||
}
|
||||
|
||||
void CrossPointWebServer::scanFiles(const char* path, const std::function<void(FileInfo)>& callback) const {
|
||||
|
||||
@@ -80,11 +80,11 @@
|
||||
}
|
||||
.breadcrumb-inline .sep {
|
||||
margin: 0 6px;
|
||||
color: var(--border-color);
|
||||
color: var(--label-color);
|
||||
}
|
||||
.breadcrumb-inline .current {
|
||||
color: var(--title-color);
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-links {
|
||||
margin: 20px 0;
|
||||
@@ -960,12 +960,6 @@
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.contents-title {
|
||||
font-size: 1.1em;
|
||||
font-weight: 600;
|
||||
color: var(--title-color);
|
||||
margin: 0;
|
||||
}
|
||||
.summary-inline {
|
||||
color: var(--label-color);
|
||||
font-size: 0.9em;
|
||||
@@ -1296,9 +1290,6 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.contents-title {
|
||||
font-size: 1em;
|
||||
}
|
||||
.summary-inline {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
@@ -1501,7 +1492,6 @@
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<h2>📁 File Manager</h2>
|
||||
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
@@ -1523,7 +1513,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="contents-header">
|
||||
<h2 class="contents-title">Contents</h2>
|
||||
<div class="breadcrumb-inline" id="directory-breadcrumbs"></div>
|
||||
<span class="summary-inline" id="folder-summary"></span>
|
||||
</div>
|
||||
|
||||
@@ -1904,19 +1894,44 @@
|
||||
const breadcrumbs = document.getElementById('directory-breadcrumbs');
|
||||
const fileTable = document.getElementById('file-table');
|
||||
|
||||
let breadcrumbContent = '<span class="sep">/</span>';
|
||||
if (currentPath === '/') {
|
||||
breadcrumbContent += '<span class="current">🏠</span>';
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
breadcrumbs.replaceChildren();
|
||||
|
||||
const appendSep = function() {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'sep';
|
||||
sep.textContent = '›';
|
||||
breadcrumbs.appendChild(sep);
|
||||
};
|
||||
|
||||
const appendLink = function(label, href) {
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.textContent = label;
|
||||
breadcrumbs.appendChild(link);
|
||||
};
|
||||
|
||||
const appendCurrent = function(label) {
|
||||
const current = document.createElement('span');
|
||||
current.className = 'current';
|
||||
current.textContent = label;
|
||||
breadcrumbs.appendChild(current);
|
||||
};
|
||||
|
||||
if (segments.length === 0) {
|
||||
appendCurrent('🏠 Home');
|
||||
} else {
|
||||
breadcrumbContent += '<a href="/files">🏠</a>';
|
||||
const pathSegments = currentPath.split('/');
|
||||
pathSegments.slice(1, pathSegments.length - 1).forEach(function(segment, index) {
|
||||
breadcrumbContent += '<span class="sep">/</span><a href="/files?path=' + encodeURIComponent(pathSegments.slice(0, index + 2).join('/')) + '">' + escapeHtml(segment) + '</a>';
|
||||
appendLink('🏠 Home', '/files');
|
||||
segments.forEach(function(segment, index) {
|
||||
appendSep();
|
||||
if (index === segments.length - 1) {
|
||||
appendCurrent(segment);
|
||||
} else {
|
||||
const path = '/' + segments.slice(0, index + 1).join('/');
|
||||
appendLink(segment, '/files?path=' + encodeURIComponent(path));
|
||||
}
|
||||
});
|
||||
breadcrumbContent += '<span class="sep">/</span>';
|
||||
breadcrumbContent += '<span class="current">' + escapeHtml(pathSegments[pathSegments.length - 1]) + '</span>';
|
||||
}
|
||||
breadcrumbs.innerHTML = breadcrumbContent;
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
|
||||
@@ -109,6 +109,10 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>Device Status</h2>
|
||||
<div class="info-row">
|
||||
<span class="label">Serial #</span>
|
||||
<span class="value" id="serial"></span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Version</span>
|
||||
<span class="value" id="version"></span>
|
||||
@@ -141,6 +145,7 @@
|
||||
}
|
||||
const data = await response.json();
|
||||
document.getElementById('version').textContent = data.version || 'N/A';
|
||||
document.getElementById('serial').textContent = data.serial;
|
||||
document.getElementById('ip-address').textContent = data.ip || 'N/A';
|
||||
document.getElementById('free-heap').textContent = data.freeHeap
|
||||
? data.freeHeap.toLocaleString() + ' bytes'
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
#include "NextBookFinder.h"
|
||||
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Memory.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <string_view>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
namespace {
|
||||
constexpr size_t NAME_BUFFER_SIZE = 500;
|
||||
|
||||
bool isSupportedBookFile(const std::string_view name) {
|
||||
// Formats ReaderActivity can open (bmp is a viewer, not a book, so it is excluded)
|
||||
return FsHelpers::hasEpubExtension(name) || FsHelpers::hasXtcExtension(name) || FsHelpers::hasTxtExtension(name) ||
|
||||
FsHelpers::hasMarkdownExtension(name);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<std::string> NextBookFinder::findNextBooks(const std::string& currentBookPath, const size_t maxCount) {
|
||||
std::vector<std::string> result;
|
||||
if (maxCount == 0 || currentBookPath.empty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const std::string folder = FsHelpers::extractFolderPath(currentBookPath);
|
||||
const auto lastSlash = currentBookPath.find_last_of('/');
|
||||
const std::string currentName =
|
||||
lastSlash == std::string::npos ? currentBookPath : currentBookPath.substr(lastSlash + 1);
|
||||
|
||||
auto dir = Storage.open(folder.c_str());
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
LOG_ERR("NBF", "Cannot open folder: %s", folder.c_str());
|
||||
return result;
|
||||
}
|
||||
dir.rewindDirectory();
|
||||
|
||||
const auto nameBuffer = makeUniqueNoThrow<char[]>(NAME_BUFFER_SIZE);
|
||||
if (!nameBuffer) {
|
||||
LOG_ERR("NBF", "OOM: %d bytes", static_cast<int>(NAME_BUFFER_SIZE));
|
||||
dir.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Heap use is bounded: at most maxCount+1 short filename strings live at once (the
|
||||
// file browser holds a whole folder in the same std::string form). A failed
|
||||
// allocation here would abort like any STL growth in this codebase; the reserve
|
||||
// below makes vector growth a single up-front allocation.
|
||||
result.reserve(maxCount + 1);
|
||||
const auto less = [](const std::string& a, const std::string& b) { return FsHelpers::naturalLess(a, b); };
|
||||
|
||||
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
||||
if (file.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
file.getName(nameBuffer.get(), NAME_BUFFER_SIZE);
|
||||
if (!SETTINGS.showHiddenFiles && nameBuffer[0] == '.') {
|
||||
continue;
|
||||
}
|
||||
if (!isSupportedBookFile(nameBuffer.get())) {
|
||||
continue;
|
||||
}
|
||||
std::string name{nameBuffer.get()};
|
||||
// Keep only files ordering strictly after the current one; equal names (the book
|
||||
// itself, or a case-variant of it) compare "not less" both ways and drop out here.
|
||||
if (!FsHelpers::naturalLess(currentName, name)) {
|
||||
continue;
|
||||
}
|
||||
// Bounded insertion sort: keep the maxCount lowest-ordering candidates
|
||||
if (result.size() >= maxCount && !less(name, result.back())) {
|
||||
continue;
|
||||
}
|
||||
const auto pos = std::lower_bound(result.begin(), result.end(), name, less);
|
||||
result.insert(pos, std::move(name));
|
||||
if (result.size() > maxCount) {
|
||||
result.pop_back();
|
||||
}
|
||||
}
|
||||
dir.close();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace NextBookFinder {
|
||||
|
||||
// Collects up to maxCount book files that order after currentBookPath's filename
|
||||
// (natural sort, same ordering as the file browser) within the same folder.
|
||||
// Returns bare filenames in sorted order; the current file itself is excluded.
|
||||
// Single directory pass keeping only the maxCount best matches, so memory stays
|
||||
// bounded regardless of folder size.
|
||||
std::vector<std::string> findNextBooks(const std::string& currentBookPath, size_t maxCount);
|
||||
|
||||
} // namespace NextBookFinder
|
||||
Reference in New Issue
Block a user