mod: Phase 1 - bring forward mod-exclusive files with ActivityManager migration
Brings ~55 mod-exclusive files to the upstream-based mod/master-resync branch: Activities (migrated to new ActivityManager pattern): - Clock/Time: SetTimeActivity, SetTimezoneOffsetActivity, NtpSyncActivity - Dictionary: DictionaryDefinitionActivity, DictionarySuggestionsActivity, DictionaryWordSelectActivity, LookedUpWordsActivity - Bookmark: EpubReaderBookmarkSelectionActivity - Book management: BookManageMenuActivity, EndOfBookMenuActivity - OPDS: OpdsServerListActivity, OpdsSettingsActivity - Utility: DirectoryPickerActivity, NumericStepperActivity Utilities (unchanged): - BookManager, BookSettings, BookmarkStore, BootNtpSync - Dictionary, LookupHistory, TimeSync, OpdsServerStore Libraries: PlaceholderCover, TableData, ChapterXPathIndexer Scripts: inject_mod_version, generate_book_icon, preview_placeholder_cover Docs: KOReader sync XPath mapping Migration changes: - ActivityWithSubactivity -> Activity base class - Callback constructors -> finish()/setResult() pattern - enterNewActivity() -> startActivityForResult() - Activity::RenderLock&& -> RenderLock&& These files won't compile yet - they reference mod settings and I18n strings that will be added in subsequent phases. Made-with: Cursor
This commit is contained in:
263
src/OpdsServerStore.cpp
Normal file
263
src/OpdsServerStore.cpp
Normal file
@@ -0,0 +1,263 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <base64.h>
|
||||
#include <esp_mac.h>
|
||||
#include <mbedtls/base64.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
OpdsServerStore OpdsServerStore::instance;
|
||||
|
||||
namespace {
|
||||
constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json";
|
||||
constexpr size_t HW_KEY_LEN = 6;
|
||||
|
||||
const uint8_t* getHwKey() {
|
||||
static uint8_t key[HW_KEY_LEN] = {};
|
||||
static bool initialized = false;
|
||||
if (!initialized) {
|
||||
esp_efuse_mac_get_default(key);
|
||||
initialized = true;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
void xorTransform(std::string& data) {
|
||||
const uint8_t* key = getHwKey();
|
||||
for (size_t i = 0; i < data.size(); i++) {
|
||||
data[i] ^= key[i % HW_KEY_LEN];
|
||||
}
|
||||
}
|
||||
|
||||
String obfuscateToBase64(const std::string& plaintext) {
|
||||
if (plaintext.empty()) return "";
|
||||
std::string temp = plaintext;
|
||||
xorTransform(temp);
|
||||
return base64::encode(reinterpret_cast<const uint8_t*>(temp.data()), temp.size());
|
||||
}
|
||||
|
||||
std::string deobfuscateFromBase64(const char* encoded, bool* ok) {
|
||||
if (encoded == nullptr || encoded[0] == '\0') {
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
if (ok) *ok = true;
|
||||
size_t encodedLen = strlen(encoded);
|
||||
size_t decodedLen = 0;
|
||||
int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast<const unsigned char*>(encoded), encodedLen);
|
||||
if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
|
||||
LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret);
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
std::string result(decodedLen, '\0');
|
||||
ret = mbedtls_base64_decode(reinterpret_cast<unsigned char*>(&result[0]), decodedLen, &decodedLen,
|
||||
reinterpret_cast<const unsigned char*>(encoded), encodedLen);
|
||||
if (ret != 0) {
|
||||
LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret);
|
||||
if (ok) *ok = false;
|
||||
return "";
|
||||
}
|
||||
result.resize(decodedLen);
|
||||
xorTransform(result);
|
||||
return result;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
bool OpdsServerStore::saveToFile() const {
|
||||
Storage.mkdir("/.crosspoint");
|
||||
|
||||
JsonDocument doc;
|
||||
JsonArray arr = doc["servers"].to<JsonArray>();
|
||||
for (const auto& server : servers) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
obj["name"] = server.name;
|
||||
obj["url"] = server.url;
|
||||
obj["username"] = server.username;
|
||||
obj["password_obf"] = obfuscateToBase64(server.password);
|
||||
obj["download_path"] = server.downloadPath;
|
||||
obj["sort_order"] = server.sortOrder;
|
||||
obj["after_download"] = server.afterDownloadAction;
|
||||
}
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(OPDS_FILE_JSON, json);
|
||||
}
|
||||
|
||||
bool OpdsServerStore::loadFromFile() {
|
||||
if (Storage.exists(OPDS_FILE_JSON)) {
|
||||
String json = Storage.readFile(OPDS_FILE_JSON);
|
||||
if (!json.isEmpty()) {
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json.c_str());
|
||||
if (error) {
|
||||
LOG_ERR("OPS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
servers.clear();
|
||||
bool needsResave = false;
|
||||
JsonArray arr = doc["servers"].as<JsonArray>();
|
||||
for (JsonObject obj : arr) {
|
||||
if (servers.size() >= MAX_SERVERS) break;
|
||||
OpdsServer server;
|
||||
server.name = obj["name"] | std::string("");
|
||||
server.url = obj["url"] | std::string("");
|
||||
server.username = obj["username"] | std::string("");
|
||||
|
||||
bool ok = false;
|
||||
server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok);
|
||||
if (!ok || server.password.empty()) {
|
||||
server.password = obj["password"] | std::string("");
|
||||
if (!server.password.empty()) needsResave = true;
|
||||
}
|
||||
server.downloadPath = obj["download_path"] | std::string("/");
|
||||
server.sortOrder = obj["sort_order"] | 0;
|
||||
server.afterDownloadAction = obj["after_download"] | 0;
|
||||
if (server.sortOrder == 0) needsResave = true;
|
||||
servers.push_back(std::move(server));
|
||||
}
|
||||
|
||||
// Assign sequential sort orders to servers loaded without one
|
||||
bool anyZero = false;
|
||||
for (const auto& s : servers) {
|
||||
if (s.sortOrder == 0) {
|
||||
anyZero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (anyZero) {
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
if (servers[i].sortOrder == 0) {
|
||||
servers[i].sortOrder = static_cast<int>(i) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortServers();
|
||||
LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size());
|
||||
|
||||
if (needsResave) {
|
||||
LOG_DBG("OPS", "Resaving JSON with sort_order / obfuscated passwords");
|
||||
saveToFile();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No opds.json found — attempt one-time migration from the legacy single-server
|
||||
// fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword).
|
||||
if (migrateFromSettings()) {
|
||||
LOG_DBG("OPS", "Migrated legacy OPDS settings");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::migrateFromSettings() {
|
||||
if (strlen(SETTINGS.opdsServerUrl) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
OpdsServer server;
|
||||
server.name = "OPDS Server";
|
||||
server.url = SETTINGS.opdsServerUrl;
|
||||
server.username = SETTINGS.opdsUsername;
|
||||
server.password = SETTINGS.opdsPassword;
|
||||
server.sortOrder = 1;
|
||||
servers.push_back(std::move(server));
|
||||
|
||||
if (saveToFile()) {
|
||||
SETTINGS.opdsServerUrl[0] = '\0';
|
||||
SETTINGS.opdsUsername[0] = '\0';
|
||||
SETTINGS.opdsPassword[0] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json");
|
||||
return true;
|
||||
}
|
||||
|
||||
servers.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool OpdsServerStore::addServer(const OpdsServer& server) {
|
||||
if (servers.size() >= MAX_SERVERS) {
|
||||
LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS);
|
||||
return false;
|
||||
}
|
||||
|
||||
servers.push_back(server);
|
||||
if (servers.back().sortOrder == 0) {
|
||||
int maxOrder = 0;
|
||||
for (size_t i = 0; i + 1 < servers.size(); i++) {
|
||||
maxOrder = std::max(maxOrder, servers[i].sortOrder);
|
||||
}
|
||||
servers.back().sortOrder = maxOrder + 1;
|
||||
}
|
||||
LOG_DBG("OPS", "Added server: %s (order=%d)", server.name.c_str(), servers.back().sortOrder);
|
||||
sortServers();
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
servers[index] = server;
|
||||
sortServers();
|
||||
LOG_DBG("OPS", "Updated server: %s (order=%d)", server.name.c_str(), server.sortOrder);
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
bool OpdsServerStore::removeServer(size_t index) {
|
||||
if (index >= servers.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str());
|
||||
servers.erase(servers.begin() + static_cast<ptrdiff_t>(index));
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
if (index >= servers.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
|
||||
bool OpdsServerStore::moveServer(size_t index, int direction) {
|
||||
if (index >= servers.size()) return false;
|
||||
|
||||
size_t target;
|
||||
if (direction < 0) {
|
||||
if (index == 0) return false;
|
||||
target = index - 1;
|
||||
} else {
|
||||
if (index + 1 >= servers.size()) return false;
|
||||
target = index + 1;
|
||||
}
|
||||
|
||||
std::swap(servers[index].sortOrder, servers[target].sortOrder);
|
||||
sortServers();
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
void OpdsServerStore::sortServers() {
|
||||
std::sort(servers.begin(), servers.end(), [](const OpdsServer& a, const OpdsServer& b) {
|
||||
if (a.sortOrder != b.sortOrder) return a.sortOrder < b.sortOrder;
|
||||
const auto& nameA = a.name.empty() ? a.url : a.name;
|
||||
const auto& nameB = b.name.empty() ? b.url : b.name;
|
||||
return std::lexicographical_compare(nameA.begin(), nameA.end(), nameB.begin(), nameB.end(),
|
||||
[](char ca, char cb) { return tolower(ca) < tolower(cb); });
|
||||
});
|
||||
}
|
||||
58
src/OpdsServerStore.h
Normal file
58
src/OpdsServerStore.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct OpdsServer {
|
||||
std::string name;
|
||||
std::string url;
|
||||
std::string username;
|
||||
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
|
||||
std::string downloadPath = "/";
|
||||
int sortOrder = 0; // Lower values appear first; ties broken alphabetically by name
|
||||
int afterDownloadAction = 0; // 0 = back to listing, 1 = open book
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton class for storing OPDS server configurations on the SD card.
|
||||
* Passwords are XOR-obfuscated with the device's unique hardware MAC address
|
||||
* and base64-encoded before writing to JSON.
|
||||
*/
|
||||
class OpdsServerStore {
|
||||
private:
|
||||
static OpdsServerStore instance;
|
||||
std::vector<OpdsServer> servers;
|
||||
|
||||
static constexpr size_t MAX_SERVERS = 8;
|
||||
|
||||
OpdsServerStore() = default;
|
||||
|
||||
public:
|
||||
OpdsServerStore(const OpdsServerStore&) = delete;
|
||||
OpdsServerStore& operator=(const OpdsServerStore&) = delete;
|
||||
|
||||
static OpdsServerStore& getInstance() { return instance; }
|
||||
|
||||
bool saveToFile() const;
|
||||
bool loadFromFile();
|
||||
|
||||
bool addServer(const OpdsServer& server);
|
||||
bool updateServer(size_t index, const OpdsServer& server);
|
||||
bool removeServer(size_t index);
|
||||
bool moveServer(size_t index, int direction);
|
||||
|
||||
const std::vector<OpdsServer>& getServers() const { return servers; }
|
||||
const OpdsServer* getServer(size_t index) const;
|
||||
size_t getCount() const { return servers.size(); }
|
||||
bool hasServers() const { return !servers.empty(); }
|
||||
|
||||
/**
|
||||
* Migrate from legacy single-server settings in CrossPointSettings.
|
||||
* Called once during first load if no opds.json exists.
|
||||
*/
|
||||
bool migrateFromSettings();
|
||||
|
||||
private:
|
||||
void sortServers();
|
||||
};
|
||||
|
||||
#define OPDS_STORE OpdsServerStore::getInstance()
|
||||
118
src/activities/home/BookManageMenuActivity.cpp
Normal file
118
src/activities/home/BookManageMenuActivity.cpp
Normal file
@@ -0,0 +1,118 @@
|
||||
#include "BookManageMenuActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void BookManageMenuActivity::buildMenuItems() {
|
||||
menuItems.clear();
|
||||
if (archived) {
|
||||
menuItems.push_back({Action::UNARCHIVE, StrId::STR_UNARCHIVE_BOOK});
|
||||
} else {
|
||||
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
|
||||
}
|
||||
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
|
||||
menuItems.push_back({Action::DELETE_CACHE, StrId::STR_DELETE_CACHE_ONLY});
|
||||
menuItems.push_back({Action::REINDEX, StrId::STR_REINDEX_BOOK});
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void BookManageMenuActivity::loop() {
|
||||
// Long-press detection: REINDEX_FULL when long-pressing on the Reindex item
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
|
||||
if (!ignoreNextConfirmRelease && selectedIndex < static_cast<int>(menuItems.size()) &&
|
||||
menuItems[selectedIndex].action == Action::REINDEX) {
|
||||
ignoreNextConfirmRelease = true;
|
||||
setResult(MenuResult{.action = static_cast<int>(Action::REINDEX_FULL)});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
if (selectedIndex < static_cast<int>(menuItems.size())) {
|
||||
setResult(MenuResult{.action = static_cast<int>(menuItems[selectedIndex].action)});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void BookManageMenuActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
constexpr int popupMargin = 20;
|
||||
constexpr int lineHeight = 30;
|
||||
constexpr int titleHeight = 40;
|
||||
const int optionCount = static_cast<int>(menuItems.size());
|
||||
const int popupH = titleHeight + popupMargin + lineHeight * optionCount + popupMargin;
|
||||
const int popupW = pageWidth - 60;
|
||||
const int popupX = (pageWidth - popupW) / 2;
|
||||
const int popupY = (pageHeight - popupH) / 2;
|
||||
|
||||
// Popup border and background
|
||||
renderer.fillRect(popupX - 2, popupY - 2, popupW + 4, popupH + 4, true);
|
||||
renderer.fillRect(popupX, popupY, popupW, popupH, false);
|
||||
|
||||
// Title
|
||||
renderer.drawText(UI_12_FONT_ID, popupX + popupMargin, popupY + 8, tr(STR_MANAGE_BOOK), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Divider line
|
||||
const int dividerY = popupY + titleHeight;
|
||||
renderer.fillRect(popupX + 4, dividerY, popupW - 8, 1, true);
|
||||
|
||||
// Menu items
|
||||
const int startY = dividerY + popupMargin / 2;
|
||||
for (int i = 0; i < optionCount; ++i) {
|
||||
const int itemY = startY + i * lineHeight;
|
||||
const bool isSelected = (i == selectedIndex);
|
||||
|
||||
if (isSelected) {
|
||||
renderer.fillRect(popupX + 2, itemY, popupW - 4, lineHeight, true);
|
||||
}
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, popupX + popupMargin, itemY, I18N.get(menuItems[i].labelId), !isSelected);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_CANCEL), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
53
src/activities/home/BookManageMenuActivity.h
Normal file
53
src/activities/home/BookManageMenuActivity.h
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <I18n.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class BookManageMenuActivity final : public Activity {
|
||||
public:
|
||||
enum class Action {
|
||||
ARCHIVE,
|
||||
UNARCHIVE,
|
||||
DELETE,
|
||||
DELETE_CACHE,
|
||||
REINDEX,
|
||||
REINDEX_FULL,
|
||||
};
|
||||
|
||||
explicit BookManageMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& bookPath, bool isArchived,
|
||||
bool initialSkipRelease = false)
|
||||
: Activity("BookManageMenu", renderer, mappedInput),
|
||||
bookPath(bookPath),
|
||||
archived(isArchived),
|
||||
ignoreNextConfirmRelease(initialSkipRelease) {
|
||||
buildMenuItems();
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
Action action;
|
||||
StrId labelId;
|
||||
};
|
||||
|
||||
std::string bookPath;
|
||||
bool archived;
|
||||
std::vector<MenuItem> menuItems;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
bool ignoreNextConfirmRelease;
|
||||
static constexpr unsigned long LONG_PRESS_MS = 700;
|
||||
|
||||
void buildMenuItems();
|
||||
};
|
||||
523
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
523
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
@@ -0,0 +1,523 @@
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void DictionaryDefinitionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
wrapText();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
|
||||
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
|
||||
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
|
||||
// ---------------------------------------------------------------------------
|
||||
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
|
||||
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
|
||||
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
|
||||
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
|
||||
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
|
||||
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
|
||||
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML entity decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
|
||||
// Named entities
|
||||
if (entity == "amp") return "&";
|
||||
if (entity == "lt") return "<";
|
||||
if (entity == "gt") return ">";
|
||||
if (entity == "quot") return "\"";
|
||||
if (entity == "apos") return "'";
|
||||
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
|
||||
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
|
||||
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
|
||||
if (entity == "lsquo") return "\xE2\x80\x98";
|
||||
if (entity == "rsquo") return "\xE2\x80\x99";
|
||||
if (entity == "ldquo") return "\xE2\x80\x9C";
|
||||
if (entity == "rdquo") return "\xE2\x80\x9D";
|
||||
if (entity == "hellip") return "\xE2\x80\xA6";
|
||||
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
|
||||
|
||||
// Numeric entities: { or 
|
||||
if (!entity.empty() && entity[0] == '#') {
|
||||
unsigned long cp = 0;
|
||||
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
|
||||
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
|
||||
} else {
|
||||
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
|
||||
}
|
||||
if (cp > 0 && cp < 0x80) {
|
||||
return std::string(1, static_cast<char>(cp));
|
||||
}
|
||||
if (cp >= 0x80 && cp < 0x800) {
|
||||
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 2);
|
||||
}
|
||||
if (cp >= 0x800 && cp < 0x10000) {
|
||||
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
|
||||
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 3);
|
||||
}
|
||||
if (cp >= 0x10000 && cp < 0x110000) {
|
||||
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
|
||||
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||
return std::string(buf, 4);
|
||||
}
|
||||
}
|
||||
|
||||
return ""; // unknown entity — drop it
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTML → TextAtom list
|
||||
// ---------------------------------------------------------------------------
|
||||
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
|
||||
std::vector<TextAtom> atoms;
|
||||
|
||||
bool isBold = false;
|
||||
bool isItalic = false;
|
||||
bool inSvg = false;
|
||||
int svgDepth = 0;
|
||||
std::vector<ListState> listStack;
|
||||
std::string currentWord;
|
||||
|
||||
auto currentStyle = [&]() -> EpdFontFamily::Style {
|
||||
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
|
||||
if (isBold) return EpdFontFamily::BOLD;
|
||||
if (isItalic) return EpdFontFamily::ITALIC;
|
||||
return EpdFontFamily::REGULAR;
|
||||
};
|
||||
|
||||
auto flushWord = [&]() {
|
||||
if (!currentWord.empty() && !inSvg) {
|
||||
atoms.push_back({currentWord, currentStyle(), false, 0});
|
||||
currentWord.clear();
|
||||
}
|
||||
};
|
||||
|
||||
auto indentPx = [&]() -> int {
|
||||
// 15 pixels per nesting level (the first level has no extra indent)
|
||||
int depth = static_cast<int>(listStack.size());
|
||||
return (depth > 1) ? (depth - 1) * 15 : 0;
|
||||
};
|
||||
|
||||
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
|
||||
// that appears before the first tag in sametypesequence=h entries.
|
||||
size_t i = 0;
|
||||
{
|
||||
size_t firstTag = html.find('<');
|
||||
if (firstTag != std::string::npos) i = firstTag;
|
||||
}
|
||||
|
||||
while (i < html.size()) {
|
||||
// ------- HTML tag -------
|
||||
if (html[i] == '<') {
|
||||
flushWord();
|
||||
|
||||
size_t tagEnd = html.find('>', i);
|
||||
if (tagEnd == std::string::npos) break;
|
||||
|
||||
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
|
||||
|
||||
// Extract tag name: first token, lowercased, trailing '/' stripped.
|
||||
size_t space = tagContent.find(' ');
|
||||
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
|
||||
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
|
||||
|
||||
// --- SVG handling (skip all content inside <svg>…</svg>) ---
|
||||
if (tagName == "svg") {
|
||||
inSvg = true;
|
||||
svgDepth = 1;
|
||||
} else if (inSvg) {
|
||||
if (tagName == "svg") {
|
||||
svgDepth++;
|
||||
} else if (tagName == "/svg") {
|
||||
svgDepth--;
|
||||
if (svgDepth <= 0) inSvg = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inSvg) {
|
||||
// --- Inline style tags ---
|
||||
if (tagName == "b" || tagName == "strong") {
|
||||
isBold = true;
|
||||
} else if (tagName == "/b" || tagName == "/strong") {
|
||||
isBold = false;
|
||||
} else if (tagName == "i" || tagName == "em") {
|
||||
isItalic = true;
|
||||
} else if (tagName == "/i" || tagName == "/em") {
|
||||
isItalic = false;
|
||||
|
||||
// --- Block-level tags → newlines ---
|
||||
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
// Headings get bold style applied to following text
|
||||
if (tagName != "p") isBold = true;
|
||||
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
isBold = false;
|
||||
} else if (tagName == "br") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
|
||||
// --- Separator between definition entries ---
|
||||
} else if (tagName == "/html") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
|
||||
isBold = false;
|
||||
isItalic = false;
|
||||
// Skip any raw text between </html> and the next tag — this is where
|
||||
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
|
||||
size_t nextTag = html.find('<', tagEnd + 1);
|
||||
i = (nextTag != std::string::npos) ? nextTag : html.size();
|
||||
continue;
|
||||
|
||||
// --- Lists ---
|
||||
} else if (tagName == "ol") {
|
||||
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
|
||||
listStack.push_back({0, alpha});
|
||||
} else if (tagName == "ul") {
|
||||
listStack.push_back({0, false});
|
||||
} else if (tagName == "/ol" || tagName == "/ul") {
|
||||
if (!listStack.empty()) listStack.pop_back();
|
||||
} else if (tagName == "li") {
|
||||
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||
if (!listStack.empty()) {
|
||||
auto& ls = listStack.back();
|
||||
ls.counter++;
|
||||
std::string marker;
|
||||
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
|
||||
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
|
||||
} else if (ls.isAlpha) {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
} else {
|
||||
marker = std::to_string(ls.counter) + ". ";
|
||||
}
|
||||
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
|
||||
} else {
|
||||
// Unordered list or bare <li>
|
||||
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
|
||||
}
|
||||
}
|
||||
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
|
||||
// their text content will still be emitted.
|
||||
}
|
||||
|
||||
i = tagEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip content inside SVG
|
||||
if (inSvg) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- HTML entity -------
|
||||
if (html[i] == '&') {
|
||||
size_t semicolon = html.find(';', i);
|
||||
if (semicolon != std::string::npos && semicolon - i < 16) {
|
||||
std::string entity = html.substr(i + 1, semicolon - i - 1);
|
||||
std::string decoded = decodeEntity(entity);
|
||||
if (!decoded.empty()) {
|
||||
// Treat decoded chars like normal text (could be space etc.)
|
||||
for (char dc : decoded) {
|
||||
if (dc == ' ') {
|
||||
flushWord();
|
||||
} else {
|
||||
currentWord += dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
// Not a valid entity — emit '&' literally
|
||||
currentWord += '&';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
|
||||
if (html[i] == '/' || html[i] == '[') {
|
||||
char closeDelim = (html[i] == '/') ? '/' : ']';
|
||||
size_t end = html.find(closeDelim, i + 1);
|
||||
if (end != std::string::npos && end - i < 80) {
|
||||
bool hasNonAscii = false;
|
||||
for (size_t j = i + 1; j < end; j++) {
|
||||
if (static_cast<unsigned char>(html[j]) > 127) {
|
||||
hasNonAscii = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasNonAscii) {
|
||||
flushWord();
|
||||
i = end + 1; // skip entire IPA section including delimiters
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Not IPA — fall through to treat as regular character
|
||||
}
|
||||
|
||||
// ------- Whitespace -------
|
||||
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
|
||||
flushWord();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ------- Regular character (with non-renderable character filter) -------
|
||||
{
|
||||
unsigned char byte = static_cast<unsigned char>(html[i]);
|
||||
if (byte < 0x80) {
|
||||
// ASCII — always renderable
|
||||
currentWord += html[i];
|
||||
i++;
|
||||
} else {
|
||||
// Multi-byte UTF-8: decode codepoint and check if renderable
|
||||
int seqLen = 1;
|
||||
uint32_t cp = 0;
|
||||
if ((byte & 0xE0) == 0xC0) {
|
||||
seqLen = 2;
|
||||
cp = byte & 0x1F;
|
||||
} else if ((byte & 0xF0) == 0xE0) {
|
||||
seqLen = 3;
|
||||
cp = byte & 0x0F;
|
||||
} else if ((byte & 0xF8) == 0xF0) {
|
||||
seqLen = 4;
|
||||
cp = byte & 0x07;
|
||||
} else {
|
||||
i++;
|
||||
continue;
|
||||
} // invalid start byte
|
||||
|
||||
if (i + static_cast<size_t>(seqLen) > html.size()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool valid = true;
|
||||
for (int j = 1; j < seqLen; j++) {
|
||||
unsigned char cb = static_cast<unsigned char>(html[i + j]);
|
||||
if ((cb & 0xC0) != 0x80) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
cp = (cp << 6) | (cb & 0x3F);
|
||||
}
|
||||
|
||||
if (valid && isRenderableCodepoint(cp)) {
|
||||
for (int j = 0; j < seqLen; j++) {
|
||||
currentWord += html[i + j];
|
||||
}
|
||||
}
|
||||
// else: silently skip non-renderable character
|
||||
|
||||
i += valid ? seqLen : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushWord();
|
||||
return atoms;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Word-wrap the parsed HTML atoms into positioned line segments
|
||||
// ---------------------------------------------------------------------------
|
||||
void DictionaryDefinitionActivity::wrapText() {
|
||||
wrappedLines.clear();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int screenWidth = renderer.getScreenWidth();
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int topArea = 50;
|
||||
constexpr int bottomArea = 50;
|
||||
const int maxWidth = screenWidth - 2 * sidePadding;
|
||||
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
|
||||
|
||||
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
|
||||
if (linesPerPage < 1) linesPerPage = 1;
|
||||
|
||||
auto atoms = parseHtml(definition);
|
||||
|
||||
std::vector<Segment> currentLine;
|
||||
int currentX = 0;
|
||||
int baseIndent = 0; // indent for continuation lines within the same block
|
||||
|
||||
for (const auto& atom : atoms) {
|
||||
// ---- Newline directive ----
|
||||
if (atom.isNewline) {
|
||||
// Collapse multiple consecutive blank lines
|
||||
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
|
||||
// Already have a blank line; update indent but don't push another
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
baseIndent = atom.indent;
|
||||
currentX = baseIndent;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- Text word ----
|
||||
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
|
||||
int gap = (currentX > baseIndent) ? spaceWidth : 0;
|
||||
|
||||
// Wrap if this word won't fit
|
||||
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
currentLine.clear();
|
||||
currentX = baseIndent;
|
||||
gap = 0;
|
||||
}
|
||||
|
||||
int16_t x = static_cast<int16_t>(currentX + gap);
|
||||
currentLine.push_back({atom.text, x, atom.style});
|
||||
currentX = x + wordWidth;
|
||||
}
|
||||
|
||||
// Flush last line
|
||||
if (!currentLine.empty()) {
|
||||
wrappedLines.push_back(std::move(currentLine));
|
||||
}
|
||||
|
||||
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
|
||||
if (totalPages < 1) totalPages = 1;
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::loop() {
|
||||
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
|
||||
if (prevPage && currentPage > 0) {
|
||||
currentPage--;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (nextPage && currentPage < totalPages - 1) {
|
||||
currentPage++;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// When hasDoneButton: Done = exit to reader (not cancelled). Otherwise: same as Back.
|
||||
ActivityResult r;
|
||||
r.isCancelled = !hasDoneButton;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
// Back = return to previous activity (cancelled)
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryDefinitionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
const int sidePadding = landscape ? 50 : 20;
|
||||
constexpr int titleY = 10;
|
||||
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||
constexpr int bodyStartY = 50;
|
||||
|
||||
// Title: the word in bold (UI font)
|
||||
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Separator line
|
||||
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
|
||||
|
||||
// Body: styled definition lines
|
||||
int startLine = currentPage * linesPerPage;
|
||||
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
|
||||
int y = bodyStartY + i * lineHeight;
|
||||
const auto& line = wrappedLines[startLine + i];
|
||||
for (const auto& seg : line) {
|
||||
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination indicator (bottom right)
|
||||
if (totalPages > 1) {
|
||||
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
|
||||
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
|
||||
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
|
||||
renderer.getScreenHeight() - 50, pageInfo.c_str());
|
||||
}
|
||||
|
||||
// Button hints (bottom face buttons)
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", hasDoneButton ? "Done" : "", "\xC2\xAB Page", "Page \xC2\xBB");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Side button hints (drawn in portrait coordinates for correct placement)
|
||||
{
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth();
|
||||
|
||||
constexpr int sideButtonWidth = 30;
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345;
|
||||
constexpr int cornerRadius = 6;
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2,
|
||||
truncated.c_str());
|
||||
} else {
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2,
|
||||
truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
|
||||
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
|
||||
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
|
||||
firstRender = false;
|
||||
}
|
||||
64
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
64
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
#include <EpdFontFamily.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class DictionaryDefinitionActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& headword, const std::string& definition, int readerFontId,
|
||||
uint8_t orientation, bool hasDoneButton = false)
|
||||
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||
headword(headword),
|
||||
definition(definition),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
hasDoneButton(hasDoneButton) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
|
||||
struct Segment {
|
||||
std::string text;
|
||||
int16_t x;
|
||||
EpdFontFamily::Style style;
|
||||
};
|
||||
|
||||
// An intermediate token produced by the HTML parser before word-wrapping.
|
||||
struct TextAtom {
|
||||
std::string text;
|
||||
EpdFontFamily::Style style;
|
||||
bool isNewline;
|
||||
int indent; // pixels to indent the new line (for nested lists)
|
||||
};
|
||||
|
||||
// Tracks ordered/unordered list nesting during HTML parsing.
|
||||
struct ListState {
|
||||
int counter; // incremented per <li>, 0 = not yet used
|
||||
bool isAlpha; // true for list-style-type: lower-alpha
|
||||
};
|
||||
|
||||
std::string headword;
|
||||
std::string definition;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
bool hasDoneButton; // If true, Confirm shows "Done" and finishes with !isCancelled (exit to reader)
|
||||
|
||||
std::vector<std::vector<Segment>> wrappedLines;
|
||||
int currentPage = 0;
|
||||
int linesPerPage = 0;
|
||||
int totalPages = 0;
|
||||
bool firstRender = true;
|
||||
|
||||
std::vector<TextAtom> parseHtml(const std::string& html);
|
||||
static std::string decodeEntity(const std::string& entity);
|
||||
static bool isRenderableCodepoint(uint32_t cp);
|
||||
void wrapText();
|
||||
};
|
||||
117
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
117
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "HalDisplay.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RenderLock.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
|
||||
void DictionarySuggestionsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void DictionarySuggestionsActivity::loop() {
|
||||
if (suggestions.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(suggestions.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(suggestions.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
const std::string& selected = suggestions[selectedIndex];
|
||||
std::string definition = Dictionary::lookup(selected);
|
||||
|
||||
if (definition.empty()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, selected, definition, readerFontId,
|
||||
orientation, true),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void DictionarySuggestionsActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int leftPadding = contentX + metrics.contentSidePadding;
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Header
|
||||
GUI.drawHeader(
|
||||
renderer,
|
||||
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
|
||||
"Did you mean?");
|
||||
|
||||
// Subtitle: the original word (manual, below header)
|
||||
const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5;
|
||||
std::string subtitle = "\"" + originalWord + "\" not found";
|
||||
renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str());
|
||||
|
||||
// Suggestion list
|
||||
const int listTop = subtitleY + 25;
|
||||
const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex,
|
||||
[this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr);
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
35
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
35
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class DictionarySuggestionsActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& originalWord, const std::vector<std::string>& suggestions,
|
||||
int readerFontId, uint8_t orientation, const std::string& cachePath)
|
||||
: Activity("DictionarySuggestions", renderer, mappedInput),
|
||||
originalWord(originalWord),
|
||||
suggestions(suggestions),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
cachePath(cachePath) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string originalWord;
|
||||
std::vector<std::string> suggestions;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
std::string cachePath;
|
||||
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
};
|
||||
650
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
650
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
@@ -0,0 +1,650 @@
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <climits>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
void DictionaryWordSelectActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
extractWords();
|
||||
mergeHyphenatedWords();
|
||||
if (!rows.empty()) {
|
||||
currentRow = static_cast<int>(rows.size()) / 3;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
bool DictionaryWordSelectActivity::isLandscape() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||
}
|
||||
|
||||
bool DictionaryWordSelectActivity::isInverted() const {
|
||||
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::extractWords() {
|
||||
words.clear();
|
||||
rows.clear();
|
||||
|
||||
for (const auto& element : page->elements) {
|
||||
// PageLine is the only concrete PageElement type, identified by tag
|
||||
const auto* line = static_cast<const PageLine*>(element.get());
|
||||
|
||||
const auto& block = line->getBlock();
|
||||
if (!block) continue;
|
||||
|
||||
const auto& wordList = block->getWords();
|
||||
const auto& xPosList = block->getWordXpos();
|
||||
|
||||
auto wordIt = wordList.begin();
|
||||
auto xIt = xPosList.begin();
|
||||
|
||||
while (wordIt != wordList.end() && xIt != xPosList.end()) {
|
||||
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
|
||||
int16_t screenY = line->yPos + marginTop;
|
||||
const std::string& wordText = *wordIt;
|
||||
|
||||
// Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94)
|
||||
std::vector<size_t> splitStarts;
|
||||
size_t partStart = 0;
|
||||
for (size_t i = 0; i < wordText.size();) {
|
||||
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
|
||||
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
|
||||
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(wordText[i + 2]) == 0x94)) {
|
||||
if (i > partStart) splitStarts.push_back(partStart);
|
||||
i += 3;
|
||||
partStart = i;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (partStart < wordText.size()) splitStarts.push_back(partStart);
|
||||
|
||||
if (splitStarts.size() <= 1 && partStart == 0) {
|
||||
// No dashes found -- add as a single word
|
||||
int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str());
|
||||
words.push_back({wordText, screenX, screenY, wordWidth, 0});
|
||||
} else {
|
||||
// Add each part as a separate selectable word
|
||||
for (size_t si = 0; si < splitStarts.size(); si++) {
|
||||
size_t start = splitStarts[si];
|
||||
size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size();
|
||||
// Find actual end by trimming any trailing dash bytes
|
||||
size_t textEnd = end;
|
||||
while (textEnd > start && textEnd <= wordText.size()) {
|
||||
if (textEnd >= 3 && static_cast<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
|
||||
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
|
||||
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
|
||||
static_cast<uint8_t>(wordText[textEnd - 1]) == 0x94)) {
|
||||
textEnd -= 3;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::string part = wordText.substr(start, textEnd - start);
|
||||
if (part.empty()) continue;
|
||||
|
||||
std::string prefix = wordText.substr(0, start);
|
||||
int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str());
|
||||
int16_t partWidth = renderer.getTextWidth(fontId, part.c_str());
|
||||
words.push_back({part, static_cast<int16_t>(screenX + offsetX), screenY, partWidth, 0});
|
||||
}
|
||||
}
|
||||
|
||||
++wordIt;
|
||||
++xIt;
|
||||
}
|
||||
}
|
||||
|
||||
// Group words into rows by Y position
|
||||
if (words.empty()) return;
|
||||
|
||||
int16_t currentY = words[0].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
|
||||
for (size_t i = 0; i < words.size(); i++) {
|
||||
// Allow small Y tolerance (words on same line may differ by a pixel)
|
||||
if (std::abs(words[i].screenY - currentY) > 2) {
|
||||
currentY = words[i].screenY;
|
||||
rows.push_back({currentY, {}});
|
||||
}
|
||||
words[i].row = static_cast<int16_t>(rows.size() - 1);
|
||||
rows.back().wordIndices.push_back(static_cast<int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
|
||||
for (size_t r = 0; r + 1 < rows.size(); r++) {
|
||||
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
|
||||
|
||||
int lastWordIdx = rows[r].wordIndices.back();
|
||||
const std::string& lastWord = words[lastWordIdx].text;
|
||||
if (lastWord.empty()) continue;
|
||||
|
||||
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
|
||||
bool endsWithHyphen = false;
|
||||
if (lastWord.back() == '-') {
|
||||
endsWithHyphen = true;
|
||||
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
|
||||
endsWithHyphen = true;
|
||||
}
|
||||
|
||||
if (!endsWithHyphen) continue;
|
||||
|
||||
int nextWordIdx = rows[r + 1].wordIndices.front();
|
||||
|
||||
// Set bidirectional continuation links for highlighting both parts
|
||||
words[lastWordIdx].continuationIndex = nextWordIdx;
|
||||
words[nextWordIdx].continuationOf = lastWordIdx;
|
||||
|
||||
// Build merged lookup text: remove trailing hyphen and combine
|
||||
std::string firstPart = lastWord;
|
||||
if (firstPart.back() == '-') {
|
||||
firstPart.pop_back();
|
||||
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
|
||||
firstPart.erase(firstPart.size() - 2);
|
||||
}
|
||||
std::string merged = firstPart + words[nextWordIdx].text;
|
||||
words[lastWordIdx].lookupText = merged;
|
||||
words[nextWordIdx].lookupText = merged;
|
||||
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
|
||||
}
|
||||
|
||||
// Cross-page hyphenation: last word on page + first word of next page
|
||||
if (!nextPageFirstWord.empty() && !rows.empty()) {
|
||||
int lastWordIdx = rows.back().wordIndices.back();
|
||||
const std::string& lastWord = words[lastWordIdx].text;
|
||||
if (!lastWord.empty()) {
|
||||
bool endsWithHyphen = false;
|
||||
if (lastWord.back() == '-') {
|
||||
endsWithHyphen = true;
|
||||
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
|
||||
endsWithHyphen = true;
|
||||
}
|
||||
if (endsWithHyphen) {
|
||||
std::string firstPart = lastWord;
|
||||
if (firstPart.back() == '-') {
|
||||
firstPart.pop_back();
|
||||
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
||||
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
|
||||
firstPart.erase(firstPart.size() - 2);
|
||||
}
|
||||
std::string merged = firstPart + nextPageFirstWord;
|
||||
words[lastWordIdx].lookupText = merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
|
||||
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::loop() {
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
// Button mapping depends on physical orientation:
|
||||
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
|
||||
// - Inverted: same axes but reversed directions (device is flipped 180)
|
||||
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
|
||||
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
} else if (landscape) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
} else if (inverted) {
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
} else {
|
||||
// Portrait (default)
|
||||
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||
}
|
||||
|
||||
const int rowCount = static_cast<int>(rows.size());
|
||||
|
||||
// Helper: find closest word by X position in a target row
|
||||
auto findClosestWord = [&](int targetRow) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
|
||||
int bestMatch = 0;
|
||||
int bestDist = INT_MAX;
|
||||
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
|
||||
int idx = rows[targetRow].wordIndices[i];
|
||||
int centerX = words[idx].screenX + words[idx].width / 2;
|
||||
int dist = std::abs(centerX - currentCenterX);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestMatch = i;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
// Move to previous row (wrap to bottom)
|
||||
if (rowPrevPressed) {
|
||||
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next row (wrap to top)
|
||||
if (rowNextPressed) {
|
||||
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = findClosestWord(targetRow);
|
||||
currentRow = targetRow;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to previous word (wrap to end of previous row)
|
||||
if (wordPrevPressed) {
|
||||
if (currentWordInRow > 0) {
|
||||
currentWordInRow--;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Move to next word (wrap to start of next row)
|
||||
if (wordNextPressed) {
|
||||
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
|
||||
currentWordInRow++;
|
||||
} else if (rowCount > 1) {
|
||||
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||
currentWordInRow = 0;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const std::string& rawWord = words[wordIdx].lookupText;
|
||||
std::string cleaned = Dictionary::cleanWord(rawWord);
|
||||
|
||||
if (cleaned.empty()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "No word");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
Rect popupLayout;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
}
|
||||
|
||||
bool cancelled = false;
|
||||
std::string definition = Dictionary::lookup(
|
||||
cleaned,
|
||||
[this, &popupLayout](int percent) {
|
||||
RenderLock lock(*this);
|
||||
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
||||
},
|
||||
[this, &cancelled]() -> bool {
|
||||
mappedInput.update();
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
cancelled = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
|
||||
if (!definition.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, cleaned, definition, fontId, orientation,
|
||||
true),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try stem variants (e.g., "jumped" -> "jump")
|
||||
auto stems = Dictionary::getStemVariants(cleaned);
|
||||
for (const auto& stem : stems) {
|
||||
std::string stemDef = Dictionary::lookup(stem);
|
||||
if (!stemDef.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, stem, stemDef, fontId, orientation,
|
||||
true),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find similar words for suggestions
|
||||
auto similar = Dictionary::findSimilar(cleaned, 6);
|
||||
if (!similar.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionarySuggestionsActivity>(renderer, mappedInput, cleaned, similar, fontId, orientation,
|
||||
cachePath),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Render the page content
|
||||
page->render(renderer, fontId, marginLeft, marginTop);
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& w = words[wordIdx];
|
||||
|
||||
// Draw inverted highlight behind selected word
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
|
||||
|
||||
// Highlight the other half of a hyphenated word (whether selecting first or second part)
|
||||
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
|
||||
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
|
||||
otherIdx = w.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
|
||||
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
|
||||
}
|
||||
}
|
||||
|
||||
drawHints();
|
||||
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
|
||||
void DictionaryWordSelectActivity::drawHints() {
|
||||
// Draw button hints in portrait orientation (matching physical buttons and theme).
|
||||
// Any hint whose area would overlap the selected word highlight is completely skipped,
|
||||
// leaving the page content underneath visible.
|
||||
const auto origOrientation = renderer.getOrientation();
|
||||
|
||||
// Get portrait dimensions for overlap math
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
const int portW = renderer.getScreenWidth(); // 480 in portrait
|
||||
const int portH = renderer.getScreenHeight(); // 800 in portrait
|
||||
renderer.setOrientation(origOrientation);
|
||||
|
||||
// Bottom button constants (match LyraTheme::drawButtonHints)
|
||||
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
|
||||
constexpr int buttonWidth = 80;
|
||||
constexpr int cornerRadius = 6;
|
||||
constexpr int textYOffset = 7;
|
||||
constexpr int smallButtonHeight = 15;
|
||||
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
||||
|
||||
// Side button constants (match LyraTheme::drawSideButtonHints)
|
||||
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
|
||||
constexpr int sideButtonHeight = 78;
|
||||
constexpr int sideButtonGap = 5;
|
||||
constexpr int sideTopY = 345; // topHintButtonY
|
||||
const int sideX = portW - sideButtonWidth;
|
||||
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||
|
||||
// Labels for face and side buttons depend on orientation,
|
||||
// because the physical-to-logical mapping rotates with the screen.
|
||||
const char* facePrev; // label for physical Left face button
|
||||
const char* faceNext; // label for physical Right face button
|
||||
const char* sideTop; // label for physical top side button (PageBack)
|
||||
const char* sideBottom; // label for physical bottom side button (PageForward)
|
||||
|
||||
const bool landscape = isLandscape();
|
||||
const bool inverted = isInverted();
|
||||
|
||||
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||
facePrev = "Line Up";
|
||||
faceNext = "Line Dn";
|
||||
sideTop = "Word \xC2\xBB";
|
||||
sideBottom = "\xC2\xAB Word";
|
||||
} else if (landscape) { // LANDSCAPE_CCW
|
||||
facePrev = "Line Dn";
|
||||
faceNext = "Line Up";
|
||||
sideTop = "\xC2\xAB Word";
|
||||
sideBottom = "Word \xC2\xBB";
|
||||
} else if (inverted) {
|
||||
facePrev = "Word \xC2\xBB";
|
||||
faceNext = "\xC2\xAB Word";
|
||||
sideTop = "Line Dn";
|
||||
sideBottom = "Line Up";
|
||||
} else { // Portrait (default)
|
||||
facePrev = "\xC2\xAB Word";
|
||||
faceNext = "Word \xC2\xBB";
|
||||
sideTop = "Line Up";
|
||||
sideBottom = "Line Dn";
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
|
||||
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
|
||||
const char* sideLabels[] = {sideTop, sideBottom};
|
||||
|
||||
// ---- Determine which hints overlap the selected word ----
|
||||
bool hideHint[4] = {false, false, false, false};
|
||||
bool hideSide[2] = {false, false};
|
||||
|
||||
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||
const int lineHeight = renderer.getLineHeight(fontId);
|
||||
|
||||
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
|
||||
struct Box {
|
||||
int x, y, w, h;
|
||||
};
|
||||
Box boxes[2];
|
||||
int boxCount = 0;
|
||||
|
||||
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||
const auto& sel = words[wordIdx];
|
||||
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
|
||||
boxCount = 1;
|
||||
|
||||
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
|
||||
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
|
||||
otherIdx = sel.continuationIndex;
|
||||
}
|
||||
if (otherIdx >= 0) {
|
||||
const auto& other = words[otherIdx];
|
||||
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
|
||||
boxCount = 2;
|
||||
}
|
||||
|
||||
// Convert each box from the current orientation to portrait coordinates,
|
||||
// then check overlap against both bottom and side button hints.
|
||||
for (int b = 0; b < boxCount; b++) {
|
||||
int px, py, pw, ph;
|
||||
|
||||
if (origOrientation == GfxRenderer::Orientation::Portrait) {
|
||||
px = boxes[b].x;
|
||||
py = boxes[b].y;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
|
||||
px = portW - boxes[b].x - boxes[b].w;
|
||||
py = portH - boxes[b].y - boxes[b].h;
|
||||
pw = boxes[b].w;
|
||||
ph = boxes[b].h;
|
||||
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
|
||||
px = boxes[b].y;
|
||||
py = portH - boxes[b].x - boxes[b].w;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
} else {
|
||||
px = portW - boxes[b].y - boxes[b].h;
|
||||
py = boxes[b].x;
|
||||
pw = boxes[b].h;
|
||||
ph = boxes[b].w;
|
||||
}
|
||||
|
||||
// Bottom button overlap
|
||||
int hintTop = portH - buttonHeight;
|
||||
if (py + ph > hintTop) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
|
||||
hideHint[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Side button overlap
|
||||
if (px + pw > sideX) {
|
||||
for (int s = 0; s < 2; s++) {
|
||||
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
|
||||
hideSide[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Draw all hints in portrait mode ----
|
||||
// Hidden buttons are skipped entirely so the page content underneath stays visible.
|
||||
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||
|
||||
// Bottom face buttons
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (hideHint[i]) continue;
|
||||
|
||||
const int x = buttonPositions[i];
|
||||
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
|
||||
|
||||
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
|
||||
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
||||
false, true);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
|
||||
const int tx = x + (buttonWidth - 1 - tw) / 2;
|
||||
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
|
||||
} else {
|
||||
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
||||
true, false, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
|
||||
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||
|
||||
for (int i = 0; i < 2; i++) {
|
||||
if (hideSide[i]) continue;
|
||||
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
|
||||
|
||||
// Solid background
|
||||
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||
|
||||
// Outline (rounded on inner side, square on screen edge — matches theme)
|
||||
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||
true, false, true);
|
||||
|
||||
// Truncate text if it would overflow the button height
|
||||
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||
|
||||
if (useCCW) {
|
||||
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
|
||||
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight - tw) / 2,
|
||||
truncated.c_str());
|
||||
} else {
|
||||
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
|
||||
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX, sideButtonY[i] + (sideButtonHeight + tw) / 2,
|
||||
truncated.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
renderer.setOrientation(origOrientation);
|
||||
}
|
||||
67
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
67
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
@@ -0,0 +1,67 @@
|
||||
#pragma once
|
||||
#include <Epub/Page.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class DictionaryWordSelectActivity final : public Activity {
|
||||
public:
|
||||
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
|
||||
const std::string& cachePath, uint8_t orientation,
|
||||
const std::string& nextPageFirstWord = "")
|
||||
: Activity("DictionaryWordSelect", renderer, mappedInput),
|
||||
page(std::move(page)),
|
||||
fontId(fontId),
|
||||
marginLeft(marginLeft),
|
||||
marginTop(marginTop),
|
||||
cachePath(cachePath),
|
||||
orientation(orientation),
|
||||
nextPageFirstWord(nextPageFirstWord) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct WordInfo {
|
||||
std::string text;
|
||||
std::string lookupText;
|
||||
int16_t screenX;
|
||||
int16_t screenY;
|
||||
int16_t width;
|
||||
int16_t row;
|
||||
int continuationIndex;
|
||||
int continuationOf;
|
||||
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
|
||||
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
|
||||
};
|
||||
|
||||
struct Row {
|
||||
int16_t yPos;
|
||||
std::vector<int> wordIndices;
|
||||
};
|
||||
|
||||
std::unique_ptr<Page> page;
|
||||
int fontId;
|
||||
int marginLeft;
|
||||
int marginTop;
|
||||
std::string cachePath;
|
||||
uint8_t orientation;
|
||||
std::string nextPageFirstWord;
|
||||
|
||||
std::vector<WordInfo> words;
|
||||
std::vector<Row> rows;
|
||||
int currentRow = 0;
|
||||
int currentWordInRow = 0;
|
||||
|
||||
bool isLandscape() const;
|
||||
bool isInverted() const;
|
||||
void extractWords();
|
||||
void mergeHyphenatedWords();
|
||||
void drawHints();
|
||||
};
|
||||
77
src/activities/reader/EndOfBookMenuActivity.cpp
Normal file
77
src/activities/reader/EndOfBookMenuActivity.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
#include "EndOfBookMenuActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void EndOfBookMenuActivity::buildMenuItems() {
|
||||
menuItems.clear();
|
||||
menuItems.push_back({Action::ARCHIVE, StrId::STR_ARCHIVE_BOOK});
|
||||
menuItems.push_back({Action::DELETE, StrId::STR_DELETE_BOOK});
|
||||
menuItems.push_back({Action::TABLE_OF_CONTENTS, StrId::STR_TABLE_OF_CONTENTS});
|
||||
menuItems.push_back({Action::BACK_TO_BEGINNING, StrId::STR_BACK_TO_BEGINNING});
|
||||
menuItems.push_back({Action::CLOSE_BOOK, StrId::STR_CLOSE_BOOK});
|
||||
menuItems.push_back({Action::CLOSE_MENU, StrId::STR_CLOSE_MENU});
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EndOfBookMenuActivity::loop() {
|
||||
buttonNavigator.onNext([this] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(menuItems.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectedIndex < static_cast<int>(menuItems.size())) {
|
||||
setResult(MenuResult{.action = static_cast<int>(menuItems[selectedIndex].action)});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void EndOfBookMenuActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_END_OF_BOOK));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(menuItems.size()), selectedIndex,
|
||||
[this](int index) { return std::string(I18N.get(menuItems[index].labelId)); });
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
44
src/activities/reader/EndOfBookMenuActivity.h
Normal file
44
src/activities/reader/EndOfBookMenuActivity.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <I18n.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EndOfBookMenuActivity final : public Activity {
|
||||
public:
|
||||
enum class Action {
|
||||
ARCHIVE,
|
||||
DELETE,
|
||||
TABLE_OF_CONTENTS,
|
||||
BACK_TO_BEGINNING,
|
||||
CLOSE_BOOK,
|
||||
CLOSE_MENU,
|
||||
};
|
||||
|
||||
explicit EndOfBookMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& bookPath)
|
||||
: Activity("EndOfBookMenu", renderer, mappedInput), bookPath(bookPath) {
|
||||
buildMenuItems();
|
||||
}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
struct MenuItem {
|
||||
Action action;
|
||||
StrId labelId;
|
||||
};
|
||||
|
||||
std::string bookPath;
|
||||
std::vector<MenuItem> menuItems;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
void buildMenuItems();
|
||||
};
|
||||
223
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
223
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
@@ -0,0 +1,223 @@
|
||||
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
|
||||
|
||||
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
|
||||
constexpr int lineHeight = 30;
|
||||
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||
const int startY = 60 + hintGutterHeight;
|
||||
const int availableHeight = screenHeight - startY - lineHeight;
|
||||
return std::max(1, availableHeight / lineHeight);
|
||||
}
|
||||
|
||||
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
|
||||
std::string label;
|
||||
if (epub) {
|
||||
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
|
||||
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
|
||||
label = epub->getTocItem(tocIndex).title;
|
||||
} else {
|
||||
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||
}
|
||||
} else {
|
||||
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||
}
|
||||
if (!bookmark.snippet.empty()) {
|
||||
label += " - " + bookmark.snippet;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
|
||||
return " - Page " + std::to_string(bookmark.pageNumber + 1);
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::loop() {
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
if (totalItems == 0) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteConfirmMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
|
||||
bookmarks[pendingDeleteIndex].pageNumber);
|
||||
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
|
||||
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
|
||||
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
|
||||
}
|
||||
deleteConfirmMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
deleteConfirmMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectorIndex;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||
const auto& b = bookmarks[selectorIndex];
|
||||
setResult(SyncResult{.spineIndex = b.spineIndex, .page = b.pageNumber});
|
||||
finish();
|
||||
} else {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
}
|
||||
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void EpubReaderBookmarkSelectionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto orientation = renderer.getOrientation();
|
||||
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int contentWidth = pageWidth - hintGutterWidth;
|
||||
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||
const int contentY = hintGutterHeight;
|
||||
const int pageItems = getPageItems();
|
||||
const int totalItems = getTotalItems();
|
||||
|
||||
const int titleX =
|
||||
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
|
||||
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
|
||||
|
||||
if (totalItems == 0) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
|
||||
} else {
|
||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
|
||||
|
||||
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
|
||||
|
||||
for (int i = 0; i < pageItems; i++) {
|
||||
int itemIndex = pageStartIndex + i;
|
||||
if (itemIndex >= totalItems) break;
|
||||
const int displayY = 60 + contentY + i * 30;
|
||||
const bool isSelected = (itemIndex == selectorIndex);
|
||||
|
||||
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
|
||||
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
|
||||
|
||||
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
|
||||
const std::string truncatedPrefix =
|
||||
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
|
||||
|
||||
const std::string label = truncatedPrefix + suffix;
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
|
||||
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
|
||||
std::string msg = "Delete bookmark" + suffix + "?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
constexpr int popupY = 200;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int w = textWidth + margin * 2;
|
||||
const int h = textHeight + margin * 2;
|
||||
const int x = (renderer.getScreenWidth() - w) / 2;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
|
||||
const int textX = x + (w - textWidth) / 2;
|
||||
const int textY = popupY + margin - 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
if (!bookmarks.empty()) {
|
||||
const char* deleteHint = "Hold select to delete";
|
||||
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
|
||||
deleteHint);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
38
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
38
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
#include <Epub.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "util/BookmarkStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class EpubReaderBookmarkSelectionActivity final : public Activity {
|
||||
std::shared_ptr<Epub> epub;
|
||||
std::vector<Bookmark> bookmarks;
|
||||
std::string cachePath;
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectorIndex = 0;
|
||||
bool deleteConfirmMode = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
|
||||
int getPageItems() const;
|
||||
int getTotalItems() const;
|
||||
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
|
||||
static std::string getPageSuffix(const Bookmark& bookmark);
|
||||
|
||||
public:
|
||||
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::shared_ptr<Epub>& epub, std::vector<Bookmark> bookmarks,
|
||||
const std::string& cachePath)
|
||||
: Activity("EpubReaderBookmarkSelection", renderer, mappedInput),
|
||||
epub(epub),
|
||||
bookmarks(std::move(bookmarks)),
|
||||
cachePath(cachePath) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
};
|
||||
300
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
300
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
@@ -0,0 +1,300 @@
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionarySuggestionsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/Dictionary.h"
|
||||
#include "util/LookupHistory.h"
|
||||
|
||||
void LookedUpWordsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
words = LookupHistory::load(cachePath);
|
||||
std::reverse(words.begin(), words.end());
|
||||
// Append the "Delete Dictionary Cache" sentinel entry
|
||||
words.push_back("\xE2\x80\x94 " + std::string(tr(STR_DELETE_DICT_CACHE)));
|
||||
deleteDictCacheIndex = static_cast<int>(words.size()) - 1;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void LookedUpWordsActivity::loop() {
|
||||
// Empty list has only the sentinel entry; if even that's gone, just go back.
|
||||
if (words.empty()) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||
if (deleteConfirmMode) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (ignoreNextConfirmRelease) {
|
||||
// Ignore the release from the initial long press
|
||||
ignoreNextConfirmRelease = false;
|
||||
} else {
|
||||
// Confirm delete
|
||||
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
|
||||
words.erase(words.begin() + pendingDeleteIndex);
|
||||
// Adjust sentinel index since we removed an item before it
|
||||
if (deleteDictCacheIndex > pendingDeleteIndex) {
|
||||
deleteDictCacheIndex--;
|
||||
}
|
||||
if (selectedIndex >= static_cast<int>(words.size())) {
|
||||
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
|
||||
}
|
||||
deleteConfirmMode = false;
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
deleteConfirmMode = false;
|
||||
ignoreNextConfirmRelease = false;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect long press on Confirm to trigger delete (only for real word entries, not sentinel)
|
||||
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||
if (selectedIndex != deleteDictCacheIndex && mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||
deleteConfirmMode = true;
|
||||
ignoreNextConfirmRelease = true;
|
||||
pendingDeleteIndex = selectedIndex;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const int totalItems = static_cast<int>(words.size());
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Consume stale release from long-press navigation into this activity
|
||||
if (ignoreNextConfirmRelease) {
|
||||
ignoreNextConfirmRelease = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle the "Delete Dictionary Cache" sentinel entry
|
||||
if (selectedIndex == deleteDictCacheIndex) {
|
||||
if (Dictionary::cacheExists()) {
|
||||
Dictionary::deleteCache();
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_DICT_CACHE_DELETED));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, tr(STR_NO_CACHE_TO_DELETE));
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string& headword = words[selectedIndex];
|
||||
|
||||
Rect popupLayout;
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||
}
|
||||
std::string definition = Dictionary::lookup(headword, [this, &popupLayout](int percent) {
|
||||
RenderLock lock(*this);
|
||||
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
||||
});
|
||||
|
||||
if (!definition.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, headword, definition, readerFontId,
|
||||
orientation, true),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try stem variants
|
||||
auto stems = Dictionary::getStemVariants(headword);
|
||||
for (const auto& stem : stems) {
|
||||
std::string stemDef = Dictionary::lookup(stem);
|
||||
if (!stemDef.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionaryDefinitionActivity>(renderer, mappedInput, stem, stemDef, readerFontId,
|
||||
orientation, true),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show similar word suggestions
|
||||
auto similar = Dictionary::findSimilar(headword, 6);
|
||||
if (!similar.empty()) {
|
||||
startActivityForResult(
|
||||
std::make_unique<DictionarySuggestionsActivity>(renderer, mappedInput, headword, similar, readerFontId,
|
||||
orientation, cachePath),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
setResult(result);
|
||||
finish();
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
}
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int LookedUpWordsActivity::getPageItems() const {
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight =
|
||||
renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
return std::max(1, contentHeight / metrics.listRowHeight);
|
||||
}
|
||||
|
||||
void LookedUpWordsActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto orient = renderer.getOrientation();
|
||||
const auto metrics = UITheme::getInstance().getMetrics();
|
||||
const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
|
||||
const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||
const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted;
|
||||
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0;
|
||||
const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0;
|
||||
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||
const int pageWidth = renderer.getScreenWidth();
|
||||
const int pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Header
|
||||
GUI.drawHeader(
|
||||
renderer,
|
||||
Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight},
|
||||
"Lookup History");
|
||||
|
||||
const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// The list always has at least the sentinel entry
|
||||
const bool hasRealWords = (deleteDictCacheIndex > 0);
|
||||
|
||||
if (words.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet");
|
||||
} else {
|
||||
GUI.drawList(
|
||||
renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
|
||||
[this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
|
||||
}
|
||||
|
||||
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
|
||||
// Draw delete confirmation overlay
|
||||
const std::string& word = words[pendingDeleteIndex];
|
||||
std::string displayWord = word;
|
||||
if (displayWord.size() > 20) {
|
||||
displayWord.erase(17);
|
||||
displayWord += "...";
|
||||
}
|
||||
std::string msg = "Delete '" + displayWord + "'?";
|
||||
|
||||
constexpr int margin = 15;
|
||||
const int popupY = 200 + hintGutterHeight;
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
const int w = textWidth + margin * 2;
|
||||
const int h = textHeight + margin * 2;
|
||||
const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2;
|
||||
|
||||
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||
renderer.fillRect(x, popupY, w, h, false);
|
||||
|
||||
const int textX = x + (w - textWidth) / 2;
|
||||
const int textY = popupY + margin - 2;
|
||||
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Button hints for delete mode
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
// "Hold select to delete" hint above button hints (only when real words exist)
|
||||
if (hasRealWords) {
|
||||
const char* deleteHint = "Hold select to delete";
|
||||
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||
const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2;
|
||||
renderer.drawText(SMALL_FONT_ID, hintX,
|
||||
renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2,
|
||||
deleteHint);
|
||||
}
|
||||
|
||||
// Normal button hints
|
||||
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
42
src/activities/reader/LookedUpWordsActivity.h
Normal file
42
src/activities/reader/LookedUpWordsActivity.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class LookedUpWordsActivity final : public Activity {
|
||||
public:
|
||||
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||
int readerFontId, uint8_t orientation, bool initialSkipRelease = false)
|
||||
: Activity("LookedUpWords", renderer, mappedInput),
|
||||
cachePath(cachePath),
|
||||
readerFontId(readerFontId),
|
||||
orientation(orientation),
|
||||
ignoreNextConfirmRelease(initialSkipRelease) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string cachePath;
|
||||
int readerFontId;
|
||||
uint8_t orientation;
|
||||
|
||||
std::vector<std::string> words;
|
||||
int selectedIndex = 0;
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
// Delete confirmation state
|
||||
bool deleteConfirmMode = false;
|
||||
bool ignoreNextConfirmRelease = false;
|
||||
int pendingDeleteIndex = 0;
|
||||
|
||||
// Sentinel index: the "Delete Dictionary Cache" entry at the end of the list.
|
||||
// -1 if not present (shouldn't happen when dictionary exists).
|
||||
int deleteDictCacheIndex = -1;
|
||||
|
||||
int getPageItems() const;
|
||||
};
|
||||
146
src/activities/settings/NtpSyncActivity.cpp
Normal file
146
src/activities/settings/NtpSyncActivity.cpp
Normal file
@@ -0,0 +1,146 @@
|
||||
#include "NtpSyncActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BootNtpSync.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
static constexpr unsigned long AUTO_DISMISS_MS = 5000;
|
||||
|
||||
void NtpSyncActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
LOG_ERR("NTP", "WiFi connection failed, exiting");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_DBG("NTP", "WiFi connected, starting NTP sync");
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = SYNCING;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
const bool synced = TimeSync::waitForNtpSync(8000);
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state = synced ? SUCCESS : FAILED;
|
||||
if (synced) {
|
||||
successTimestamp = millis();
|
||||
}
|
||||
}
|
||||
requestUpdate();
|
||||
|
||||
if (synced) {
|
||||
LOG_DBG("NTP", "Time synced successfully");
|
||||
} else {
|
||||
LOG_ERR("NTP", "NTP sync timed out");
|
||||
}
|
||||
}
|
||||
|
||||
void NtpSyncActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
BootNtpSync::cancel();
|
||||
LOG_DBG("NTP", "Turning on WiFi...");
|
||||
WiFi.mode(WIFI_STA);
|
||||
|
||||
LOG_DBG("NTP", "Launching WifiSelectionActivity...");
|
||||
startActivityForResult(
|
||||
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult& result) {
|
||||
const bool success = !result.isCancelled && std::holds_alternative<WifiResult>(result.data);
|
||||
onWifiSelectionComplete(success);
|
||||
});
|
||||
}
|
||||
|
||||
void NtpSyncActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
TimeSync::stopNtpSync();
|
||||
WiFi.disconnect(false);
|
||||
delay(100);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void NtpSyncActivity::render(RenderLock&&) {
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.clearScreen();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SYNC_CLOCK));
|
||||
|
||||
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto centerY = (pageHeight - lineHeight) / 2;
|
||||
|
||||
if (state == SYNCING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNCING_TIME));
|
||||
} else if (state == SUCCESS) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_TIME_SYNCED), true, EpdFontFamily::BOLD);
|
||||
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[32];
|
||||
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + lineHeight + metrics.verticalSpacing, timeBuf);
|
||||
}
|
||||
|
||||
const unsigned long elapsed = millis() - successTimestamp;
|
||||
const int remaining = static_cast<int>((AUTO_DISMISS_MS - elapsed + 999) / 1000);
|
||||
char backLabel[32];
|
||||
snprintf(backLabel, sizeof(backLabel), "%s (%d)", tr(STR_BACK), remaining > 0 ? remaining : 1);
|
||||
const auto labels = mappedInput.mapLabels(backLabel, "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else if (state == FAILED) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void NtpSyncActivity::loop() {
|
||||
if (state == SUCCESS) {
|
||||
const unsigned long elapsed = millis() - successTimestamp;
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) || elapsed >= AUTO_DISMISS_MS) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const int currentSecond = static_cast<int>(elapsed / 1000);
|
||||
if (currentSecond != lastCountdownSecond) {
|
||||
lastCountdownSecond = currentSecond;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == FAILED) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
22
src/activities/settings/NtpSyncActivity.h
Normal file
22
src/activities/settings/NtpSyncActivity.h
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class NtpSyncActivity : public Activity {
|
||||
enum State { WIFI_SELECTION, SYNCING, SUCCESS, FAILED };
|
||||
|
||||
State state = WIFI_SELECTION;
|
||||
unsigned long successTimestamp = 0;
|
||||
int lastCountdownSecond = -1;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
|
||||
public:
|
||||
explicit NtpSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("NtpSync", renderer, mappedInput) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state == SYNCING; }
|
||||
};
|
||||
118
src/activities/settings/OpdsServerListActivity.cpp
Normal file
118
src/activities/settings/OpdsServerListActivity.cpp
Normal file
@@ -0,0 +1,118 @@
|
||||
#include "OpdsServerListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "OpdsSettingsActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
int OpdsServerListActivity::getItemCount() const {
|
||||
int count = static_cast<int>(OPDS_STORE.getCount());
|
||||
if (!pickerMode) {
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
OPDS_STORE.loadFromFile();
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsServerListActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int itemCount = getItemCount();
|
||||
if (itemCount > 0) {
|
||||
buttonNavigator.onNext([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, itemCount] {
|
||||
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::handleSelection() {
|
||||
const auto serverCount = static_cast<int>(OPDS_STORE.getCount());
|
||||
|
||||
if (pickerMode) {
|
||||
if (selectedIndex < serverCount) {
|
||||
setResult(PageResult{.page = static_cast<uint32_t>(selectedIndex)});
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int idx = selectedIndex;
|
||||
startActivityForResult(
|
||||
std::make_unique<OpdsSettingsActivity>(renderer, mappedInput, idx < serverCount ? idx : -1),
|
||||
[this](const ActivityResult&) {
|
||||
selectedIndex = 0;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void OpdsServerListActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_OPDS_SERVERS));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int itemCount = getItemCount();
|
||||
|
||||
if (itemCount == 0) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS));
|
||||
} else {
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
const auto serverCount = static_cast<int>(servers.size());
|
||||
|
||||
// Primary label: server name (falling back to URL if unnamed).
|
||||
// Secondary label: server URL (shown as subtitle when name is set).
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex,
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount) {
|
||||
const auto& server = servers[index];
|
||||
return server.name.empty() ? server.url : server.name;
|
||||
}
|
||||
return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER));
|
||||
},
|
||||
[&servers, serverCount](int index) {
|
||||
if (index < serverCount && !servers[index].name.empty()) {
|
||||
return servers[index].url;
|
||||
}
|
||||
return std::string("");
|
||||
});
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
31
src/activities/settings/OpdsServerListActivity.h
Normal file
31
src/activities/settings/OpdsServerListActivity.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Activity showing the list of configured OPDS servers.
|
||||
* Allows adding new servers and editing/deleting existing ones.
|
||||
* Used from Settings and also as a server picker from the home screen.
|
||||
*
|
||||
* When pickerMode is true, selecting a server returns PageResult{.page = serverIndex} and finishes.
|
||||
* When pickerMode is false (settings mode), selecting opens OpdsSettingsActivity for edit/add.
|
||||
*/
|
||||
class OpdsServerListActivity final : public Activity {
|
||||
public:
|
||||
explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false)
|
||||
: Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
int selectedIndex = 0;
|
||||
bool pickerMode;
|
||||
|
||||
int getItemCount() const;
|
||||
void handleSelection();
|
||||
};
|
||||
225
src/activities/settings/OpdsSettingsActivity.cpp
Normal file
225
src/activities/settings/OpdsSettingsActivity.cpp
Normal file
@@ -0,0 +1,225 @@
|
||||
#include "OpdsSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "activities/util/NumericStepperActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Editable fields: Position, Name, URL, Username, Password, Download Path, After Download.
|
||||
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||
constexpr int BASE_ITEMS = 7;
|
||||
} // namespace
|
||||
|
||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||
return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
selectedIndex = 0;
|
||||
isNewServer = (serverIndex < 0);
|
||||
|
||||
if (!isNewServer) {
|
||||
const auto* server = OPDS_STORE.getServer(static_cast<size_t>(serverIndex));
|
||||
if (server) {
|
||||
editServer = *server;
|
||||
} else {
|
||||
// Server was deleted between navigation and entering this screen — treat as new
|
||||
isNewServer = true;
|
||||
serverIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void OpdsSettingsActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const int menuItems = getMenuItemCount();
|
||||
buttonNavigator.onNext([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPrevious([this, menuItems] {
|
||||
selectedIndex = (selectedIndex + menuItems - 1) % menuItems;
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::saveServer() {
|
||||
if (isNewServer) {
|
||||
OPDS_STORE.addServer(editServer);
|
||||
isNewServer = false;
|
||||
} else {
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
||||
}
|
||||
|
||||
// Re-locate our server after add/update may have re-sorted the vector
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
|
||||
serverIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// Position (sort order)
|
||||
startActivityForResult(
|
||||
std::make_unique<NumericStepperActivity>(renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<PageResult>(result.data)) {
|
||||
editServer.sortOrder = static_cast<int>(std::get<PageResult>(result.data).page);
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 1) {
|
||||
// Server Name
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
editServer.name = std::get<KeyboardResult>(result.data).text;
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 2) {
|
||||
// Server URL
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127,
|
||||
false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
editServer.url = std::get<KeyboardResult>(result.data).text;
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 3) {
|
||||
// Username
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63,
|
||||
false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
editServer.username = std::get<KeyboardResult>(result.data).text;
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 4) {
|
||||
// Password
|
||||
startActivityForResult(
|
||||
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63,
|
||||
false),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
editServer.password = std::get<KeyboardResult>(result.data).text;
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 5) {
|
||||
// Download Path
|
||||
startActivityForResult(
|
||||
std::make_unique<DirectoryPickerActivity>(renderer, mappedInput, editServer.downloadPath),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled && std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
editServer.downloadPath = std::get<KeyboardResult>(result.data).text;
|
||||
saveServer();
|
||||
}
|
||||
requestUpdate();
|
||||
});
|
||||
} else if (selectedIndex == 6) {
|
||||
// After Download — toggle between 0 (back to listing) and 1 (open book)
|
||||
editServer.afterDownloadAction = editServer.afterDownloadAction == 0 ? 1 : 0;
|
||||
saveServer();
|
||||
requestUpdate();
|
||||
} else if (selectedIndex == 7 && !isNewServer) {
|
||||
// Delete server
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header);
|
||||
GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight},
|
||||
tr(STR_CALIBRE_URL_HINT));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
|
||||
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH,
|
||||
StrId::STR_AFTER_DOWNLOAD};
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||
[this, &fieldNames](int index) {
|
||||
if (index < BASE_ITEMS) {
|
||||
return std::string(I18N.get(fieldNames[index]));
|
||||
}
|
||||
return std::string(tr(STR_DELETE_SERVER));
|
||||
},
|
||||
nullptr, nullptr,
|
||||
[this](int index) {
|
||||
if (index == 0) {
|
||||
return std::to_string(editServer.sortOrder);
|
||||
} else if (index == 1) {
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
} else if (index == 2) {
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
} else if (index == 3) {
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
} else if (index == 4) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 5) {
|
||||
return editServer.downloadPath;
|
||||
} else if (index == 6) {
|
||||
return std::string(editServer.afterDownloadAction == 0 ? tr(STR_BACK_TO_LISTING) : tr(STR_OPEN_BOOK));
|
||||
}
|
||||
return std::string("");
|
||||
},
|
||||
true);
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
36
src/activities/settings/OpdsSettingsActivity.h
Normal file
36
src/activities/settings/OpdsSettingsActivity.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Edit screen for a single OPDS server.
|
||||
* Shows Name, URL, Username, Password fields and a Delete option.
|
||||
* Used for both adding new servers and editing existing ones.
|
||||
*/
|
||||
class OpdsSettingsActivity final : public Activity {
|
||||
public:
|
||||
/**
|
||||
* @param serverIndex Index into OpdsServerStore, or -1 for a new server
|
||||
*/
|
||||
explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1)
|
||||
: Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
int serverIndex;
|
||||
OpdsServer editServer;
|
||||
bool isNewServer = false;
|
||||
|
||||
int getMenuItemCount() const;
|
||||
void handleSelection();
|
||||
void saveServer();
|
||||
};
|
||||
157
src/activities/settings/SetTimeActivity.cpp
Normal file
157
src/activities/settings/SetTimeActivity.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "SetTimeActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Initialize from current system time if it's been set (year > 2000)
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
hour = t->tm_hour;
|
||||
minute = t->tm_min;
|
||||
} else {
|
||||
hour = 12;
|
||||
minute = 0;
|
||||
}
|
||||
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimeActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimeActivity::loop() {
|
||||
// Back button: discard and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm button: apply time and exit
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
applyTime();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left/Right: switch between hour and minute fields
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedField = 1;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down: increment/decrement the selected field
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 1) % 24;
|
||||
} else {
|
||||
minute = (minute + 1) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 23) % 24;
|
||||
} else {
|
||||
minute = (minute + 59) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimeActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_TIME), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format hour and minute strings
|
||||
char hourStr[4];
|
||||
char minuteStr[4];
|
||||
snprintf(hourStr, sizeof(hourStr), "%02d", hour);
|
||||
snprintf(minuteStr, sizeof(minuteStr), "%02d", minute);
|
||||
|
||||
const int colonWidth = renderer.getTextWidth(UI_12_FONT_ID, " : ");
|
||||
const int digitWidth = renderer.getTextWidth(UI_12_FONT_ID, "00");
|
||||
const int totalWidth = digitWidth * 2 + colonWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
const int timeY = 80;
|
||||
|
||||
// Draw selection highlight behind the selected field
|
||||
constexpr int highlightPad = 6;
|
||||
if (selectedField == 0) {
|
||||
renderer.fillRoundedRect(startX - highlightPad, timeY - 4, digitWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
} else {
|
||||
renderer.fillRoundedRect(startX + digitWidth + colonWidth - highlightPad, timeY - 4, digitWidth + highlightPad * 2,
|
||||
lineHeight12 + 8, 6, Color::LightGray);
|
||||
}
|
||||
|
||||
// Draw the time digits and colon
|
||||
renderer.drawText(UI_12_FONT_ID, startX, timeY, hourStr, true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth, timeY, " : ", true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth + colonWidth, timeY, minuteStr, true);
|
||||
|
||||
// Draw up/down arrows above and below the selected field
|
||||
const int arrowX = (selectedField == 0) ? startX + digitWidth / 2 : startX + digitWidth + colonWidth + digitWidth / 2;
|
||||
const int arrowUpY = timeY - 20;
|
||||
const int arrowDownY = timeY + lineHeight12 + 12;
|
||||
// Up arrow (simple triangle using lines)
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
// Down arrow
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void SetTimeActivity::applyTime() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm newTime = {};
|
||||
struct tm* current = localtime(&now);
|
||||
if (current != nullptr && current->tm_year > 100) {
|
||||
newTime = *current;
|
||||
} else {
|
||||
// If time was never set, use a reasonable date (2025-01-01)
|
||||
newTime.tm_year = 125; // years since 1900
|
||||
newTime.tm_mon = 0;
|
||||
newTime.tm_mday = 1;
|
||||
}
|
||||
newTime.tm_hour = hour;
|
||||
newTime.tm_min = minute;
|
||||
newTime.tm_sec = 0;
|
||||
time_t newEpoch = mktime(&newTime);
|
||||
struct timeval tv = {.tv_sec = newEpoch, .tv_usec = 0};
|
||||
settimeofday(&tv, nullptr);
|
||||
}
|
||||
23
src/activities/settings/SetTimeActivity.h
Normal file
23
src/activities/settings/SetTimeActivity.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimeActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("SetTime", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
|
||||
// 0 = editing hours, 1 = editing minutes
|
||||
uint8_t selectedField = 0;
|
||||
int hour = 12;
|
||||
int minute = 0;
|
||||
|
||||
void applyTime();
|
||||
};
|
||||
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
101
src/activities/settings/SetTimezoneOffsetActivity.cpp
Normal file
@@ -0,0 +1,101 @@
|
||||
#include "SetTimezoneOffsetActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimezoneOffsetActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
offsetHours = SETTINGS.timezoneOffsetHours;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimezoneOffsetActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
SETTINGS.timezoneOffsetHours = offsetHours;
|
||||
SETTINGS.saveToFile();
|
||||
// Apply timezone immediately
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (offsetHours < 14) {
|
||||
offsetHours++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (offsetHours > -12) {
|
||||
offsetHours--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimezoneOffsetActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_UTC_OFFSET), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format the offset string
|
||||
char offsetStr[16];
|
||||
if (offsetHours >= 0) {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC+%d", offsetHours);
|
||||
} else {
|
||||
snprintf(offsetStr, sizeof(offsetStr), "UTC%d", offsetHours);
|
||||
}
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, offsetStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = 80;
|
||||
|
||||
// Draw selection highlight
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
|
||||
// Draw the offset text
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, offsetStr, true);
|
||||
|
||||
// Draw up/down arrows
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
17
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
17
src/activities/settings/SetTimezoneOffsetActivity.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimezoneOffsetActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimezoneOffsetActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("SetTZOffset", renderer, mappedInput) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
int8_t offsetHours = 0;
|
||||
};
|
||||
180
src/activities/util/DirectoryPickerActivity.cpp
Normal file
180
src/activities/util/DirectoryPickerActivity.cpp
Normal file
@@ -0,0 +1,180 @@
|
||||
#include "DirectoryPickerActivity.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
void DirectoryPickerActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
basepath = initialPath;
|
||||
if (basepath.empty()) basepath = "/";
|
||||
|
||||
// Validate the initial path exists; fall back to root if not
|
||||
auto dir = Storage.open(basepath.c_str());
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
basepath = "/";
|
||||
} else {
|
||||
dir.close();
|
||||
}
|
||||
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::onExit() {
|
||||
directories.clear();
|
||||
Activity::onExit();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::loadDirectories() {
|
||||
directories.clear();
|
||||
|
||||
auto root = Storage.open(basepath.c_str());
|
||||
if (!root || !root.isDirectory()) {
|
||||
if (root) root.close();
|
||||
return;
|
||||
}
|
||||
|
||||
root.rewindDirectory();
|
||||
|
||||
char name[256];
|
||||
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
||||
file.getName(name, sizeof(name));
|
||||
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
|
||||
file.close();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
directories.emplace_back(std::string(name) + "/");
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
root.close();
|
||||
StringUtils::sortFileList(directories);
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::navigateToParent() {
|
||||
auto slash = basepath.find_last_of('/');
|
||||
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::loop() {
|
||||
// Absorb the Confirm release from the parent activity that launched us
|
||||
if (waitForConfirmRelease) {
|
||||
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
|
||||
waitForConfirmRelease = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int offset = directoryOffset();
|
||||
const int totalItems = offset + static_cast<int>(directories.size());
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (selectorIndex == 0) {
|
||||
setResult(KeyboardResult{.text = basepath});
|
||||
finish();
|
||||
} else if (showGoUp() && selectorIndex == 1) {
|
||||
navigateToParent();
|
||||
} else {
|
||||
const auto& dirName = directories[selectorIndex - offset];
|
||||
// Strip trailing '/'
|
||||
std::string folderName = dirName.substr(0, dirName.length() - 1);
|
||||
basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName;
|
||||
selectorIndex = 0;
|
||||
loadDirectories();
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
if (basepath == "/") {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
} else {
|
||||
navigateToParent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator.onNextRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
|
||||
|
||||
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void DirectoryPickerActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SELECT_FOLDER));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
const int offset = directoryOffset();
|
||||
const int totalItems = offset + static_cast<int>(directories.size());
|
||||
const bool goUp = showGoUp();
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex,
|
||||
[this, offset, goUp](int index) -> std::string {
|
||||
if (index == 0) {
|
||||
return std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
|
||||
}
|
||||
if (goUp && index == 1) {
|
||||
return "..";
|
||||
}
|
||||
const auto& dir = directories[index - offset];
|
||||
return dir.substr(0, dir.length() - 1);
|
||||
},
|
||||
nullptr,
|
||||
[](int index) -> UIIcon {
|
||||
return (index == 0) ? UIIcon::File : UIIcon::Folder;
|
||||
});
|
||||
|
||||
const char* backLabel = (basepath == "/") ? tr(STR_CANCEL) : tr(STR_BACK);
|
||||
const char* confirmLabel = (selectorIndex == 0) ? tr(STR_SAVE_HERE) : tr(STR_OPEN);
|
||||
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
39
src/activities/util/DirectoryPickerActivity.h
Normal file
39
src/activities/util/DirectoryPickerActivity.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
* Directory picker for selecting a save location on the SD card.
|
||||
* Shows only directories and a "Save Here" option at index 0.
|
||||
* Navigating into a subdirectory updates the current path; Back goes up.
|
||||
* Pressing Back at root cancels and finishes with isCancelled=true.
|
||||
* Selecting "Save Here" returns KeyboardResult{.text = path} and finishes.
|
||||
*/
|
||||
class DirectoryPickerActivity final : public Activity {
|
||||
public:
|
||||
explicit DirectoryPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& initialPath = "/")
|
||||
: Activity("DirectoryPicker", renderer, mappedInput), initialPath(initialPath) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
ButtonNavigator buttonNavigator;
|
||||
std::string initialPath;
|
||||
std::string basepath = "/";
|
||||
std::vector<std::string> directories;
|
||||
int selectorIndex = 0;
|
||||
bool waitForConfirmRelease = true;
|
||||
|
||||
void loadDirectories();
|
||||
void navigateToParent();
|
||||
[[nodiscard]] bool showGoUp() const { return basepath != "/"; }
|
||||
[[nodiscard]] int directoryOffset() const { return showGoUp() ? 2 : 1; }
|
||||
};
|
||||
104
src/activities/util/NumericStepperActivity.cpp
Normal file
104
src/activities/util/NumericStepperActivity.cpp
Normal file
@@ -0,0 +1,104 @@
|
||||
#include "NumericStepperActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void NumericStepperActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void NumericStepperActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void NumericStepperActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
ActivityResult r;
|
||||
r.isCancelled = true;
|
||||
setResult(std::move(r));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
setResult(PageResult{.page = static_cast<uint32_t>(value)});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// Side buttons: ±1
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (value < maxValue) {
|
||||
value++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (value > minValue) {
|
||||
value--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Front face buttons: ±10
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
value = std::min(value + 10, maxValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
value = std::max(value - 10, minValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void NumericStepperActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str());
|
||||
|
||||
char valueStr[16];
|
||||
snprintf(valueStr, sizeof(valueStr), "%d", value);
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = metrics.topPadding + metrics.headerHeight + 60;
|
||||
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true);
|
||||
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawSideButtonHints(renderer, "+1", "-1");
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
33
src/activities/util/NumericStepperActivity.h
Normal file
33
src/activities/util/NumericStepperActivity.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* Reusable numeric stepper for integer value entry.
|
||||
* Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10.
|
||||
* Value is clamped within [min, max].
|
||||
*/
|
||||
class NumericStepperActivity final : public Activity {
|
||||
public:
|
||||
explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title,
|
||||
int value, int minValue, int maxValue)
|
||||
: Activity("NumericStepper", renderer, mappedInput),
|
||||
title(std::move(title)),
|
||||
value(value),
|
||||
minValue(minValue),
|
||||
maxValue(maxValue) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string title;
|
||||
int value;
|
||||
int minValue;
|
||||
int maxValue;
|
||||
};
|
||||
255
src/util/BookManager.cpp
Normal file
255
src/util/BookManager.cpp
Normal file
@@ -0,0 +1,255 @@
|
||||
#include "BookManager.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "RecentBooksStore.h"
|
||||
#include "StringUtils.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char ARCHIVE_ROOT[] = "/.archive";
|
||||
constexpr char CACHE_ROOT[] = "/.crosspoint";
|
||||
|
||||
std::string getCachePrefix(const std::string& bookPath) {
|
||||
if (StringUtils::checkFileExtension(bookPath, ".epub")) return "epub_";
|
||||
if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch"))
|
||||
return "xtc_";
|
||||
if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".md"))
|
||||
return "txt_";
|
||||
return "epub_";
|
||||
}
|
||||
|
||||
std::string computeCachePath(const std::string& bookPath) {
|
||||
const auto prefix = getCachePrefix(bookPath);
|
||||
return std::string(CACHE_ROOT) + "/" + prefix + std::to_string(std::hash<std::string>{}(bookPath));
|
||||
}
|
||||
|
||||
// Ensure all parent directories of a path exist.
|
||||
void ensureParentDirs(const std::string& path) {
|
||||
for (size_t i = 1; i < path.length(); i++) {
|
||||
if (path[i] == '/') {
|
||||
Storage.mkdir(path.substr(0, i).c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete cover and thumbnail BMP files from a cache directory.
|
||||
void deleteCoverFiles(const std::string& cachePath) {
|
||||
auto dir = Storage.open(cachePath.c_str());
|
||||
if (!dir || !dir.isDirectory()) {
|
||||
if (dir) dir.close();
|
||||
return;
|
||||
}
|
||||
dir.rewindDirectory();
|
||||
|
||||
char name[256];
|
||||
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
||||
file.getName(name, sizeof(name));
|
||||
const std::string fname(name);
|
||||
file.close();
|
||||
|
||||
const bool isCover = (fname == "cover.bmp" || fname == "cover_crop.bmp");
|
||||
const bool isThumb = (fname.rfind("thumb_", 0) == 0 && StringUtils::checkFileExtension(fname, ".bmp"));
|
||||
|
||||
if (isCover || isThumb) {
|
||||
const std::string fullPath = cachePath + "/" + fname;
|
||||
Storage.remove(fullPath.c_str());
|
||||
}
|
||||
}
|
||||
dir.close();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace BookManager {
|
||||
|
||||
std::string getBookCachePath(const std::string& bookPath) { return computeCachePath(bookPath); }
|
||||
|
||||
bool isArchived(const std::string& bookPath) {
|
||||
return bookPath.rfind(std::string(ARCHIVE_ROOT) + "/", 0) == 0;
|
||||
}
|
||||
|
||||
bool archiveBook(const std::string& bookPath) {
|
||||
if (isArchived(bookPath)) {
|
||||
LOG_ERR("BKMGR", "Book is already archived: %s", bookPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
const std::string destPath = std::string(ARCHIVE_ROOT) + bookPath;
|
||||
ensureParentDirs(destPath);
|
||||
|
||||
if (!Storage.rename(bookPath.c_str(), destPath.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to move book to archive: %s -> %s", bookPath.c_str(), destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rename cache directory to match the new book path hash
|
||||
const std::string oldCache = computeCachePath(bookPath);
|
||||
const std::string newCache = computeCachePath(destPath);
|
||||
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
|
||||
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
LOG_DBG("BKMGR", "Archived: %s -> %s", bookPath.c_str(), destPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool unarchiveBook(const std::string& archivePath, std::string* unarchivedPath) {
|
||||
if (!isArchived(archivePath)) {
|
||||
LOG_ERR("BKMGR", "Book is not in archive: %s", archivePath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip "/.archive" prefix to get original path
|
||||
std::string destPath = archivePath.substr(strlen(ARCHIVE_ROOT));
|
||||
if (destPath.empty() || destPath[0] != '/') {
|
||||
destPath = "/" + destPath;
|
||||
}
|
||||
|
||||
// Check if original parent directory exists, fall back to root
|
||||
const auto lastSlash = destPath.find_last_of('/');
|
||||
std::string parentDir = (lastSlash != std::string::npos && lastSlash > 0) ? destPath.substr(0, lastSlash) : "/";
|
||||
if (!Storage.exists(parentDir.c_str())) {
|
||||
const auto filename = destPath.substr(lastSlash + 1);
|
||||
destPath = "/" + filename;
|
||||
LOG_DBG("BKMGR", "Original dir gone, unarchiving to root: %s", destPath.c_str());
|
||||
}
|
||||
|
||||
ensureParentDirs(destPath);
|
||||
|
||||
if (!Storage.rename(archivePath.c_str(), destPath.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to move book from archive: %s -> %s", archivePath.c_str(), destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rename cache directory
|
||||
const std::string oldCache = computeCachePath(archivePath);
|
||||
const std::string newCache = computeCachePath(destPath);
|
||||
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
|
||||
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
RECENT_BOOKS.removeBook(archivePath);
|
||||
LOG_DBG("BKMGR", "Unarchived: %s -> %s", archivePath.c_str(), destPath.c_str());
|
||||
if (unarchivedPath) *unarchivedPath = destPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool deleteBook(const std::string& bookPath) {
|
||||
// Delete the book file
|
||||
if (Storage.exists(bookPath.c_str())) {
|
||||
if (!Storage.remove(bookPath.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to delete book file: %s", bookPath.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete cache directory
|
||||
const std::string cachePath = computeCachePath(bookPath);
|
||||
if (Storage.exists(cachePath.c_str())) {
|
||||
Storage.removeDir(cachePath.c_str());
|
||||
}
|
||||
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
LOG_DBG("BKMGR", "Deleted book: %s", bookPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool deleteBookCache(const std::string& bookPath) {
|
||||
const std::string cachePath = computeCachePath(bookPath);
|
||||
if (Storage.exists(cachePath.c_str())) {
|
||||
if (!Storage.removeDir(cachePath.c_str())) {
|
||||
LOG_ERR("BKMGR", "Failed to delete cache: %s", cachePath.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
LOG_DBG("BKMGR", "Deleted cache for: %s", bookPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers) {
|
||||
const std::string cachePath = computeCachePath(bookPath);
|
||||
if (!Storage.exists(cachePath.c_str())) {
|
||||
LOG_DBG("BKMGR", "No cache to reindex for: %s", bookPath.c_str());
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto prefix = getCachePrefix(bookPath);
|
||||
|
||||
if (prefix == "epub_") {
|
||||
// Delete sections directory
|
||||
const std::string sectionsPath = cachePath + "/sections";
|
||||
if (Storage.exists(sectionsPath.c_str())) {
|
||||
Storage.removeDir(sectionsPath.c_str());
|
||||
}
|
||||
// Delete book.bin (spine/TOC metadata)
|
||||
const std::string bookBin = cachePath + "/book.bin";
|
||||
if (Storage.exists(bookBin.c_str())) {
|
||||
Storage.remove(bookBin.c_str());
|
||||
}
|
||||
// Delete CSS cache
|
||||
const std::string cssCache = cachePath + "/css_rules.cache";
|
||||
if (Storage.exists(cssCache.c_str())) {
|
||||
Storage.remove(cssCache.c_str());
|
||||
}
|
||||
} else if (prefix == "txt_") {
|
||||
// Delete page index
|
||||
const std::string indexBin = cachePath + "/index.bin";
|
||||
if (Storage.exists(indexBin.c_str())) {
|
||||
Storage.remove(indexBin.c_str());
|
||||
}
|
||||
} else if (prefix == "xtc_") {
|
||||
// XTC is pre-indexed; only covers/thumbs are cached
|
||||
// Nothing to delete for sections
|
||||
}
|
||||
|
||||
if (alsoRegenerateCovers) {
|
||||
deleteCoverFiles(cachePath);
|
||||
}
|
||||
|
||||
RECENT_BOOKS.removeBook(bookPath);
|
||||
LOG_DBG("BKMGR", "Reindexed (covers=%d): %s", alsoRegenerateCovers, bookPath.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
void cleanupEmptyArchiveDirs(const std::string& bookPath) {
|
||||
if (!isArchived(bookPath)) return;
|
||||
|
||||
// Walk up from the book's parent directory, removing empty dirs
|
||||
std::string dir = bookPath.substr(0, bookPath.find_last_of('/'));
|
||||
const std::string archiveRoot(ARCHIVE_ROOT);
|
||||
|
||||
while (dir.length() > archiveRoot.length()) {
|
||||
auto d = Storage.open(dir.c_str());
|
||||
if (!d || !d.isDirectory()) {
|
||||
if (d) d.close();
|
||||
break;
|
||||
}
|
||||
auto child = d.openNextFile();
|
||||
const bool empty = !child;
|
||||
if (child) child.close();
|
||||
d.close();
|
||||
|
||||
if (!empty) break;
|
||||
|
||||
Storage.rmdir(dir.c_str());
|
||||
LOG_DBG("BKMGR", "Removed empty archive dir: %s", dir.c_str());
|
||||
|
||||
auto slash = dir.find_last_of('/');
|
||||
if (slash == std::string::npos || slash == 0) break;
|
||||
dir = dir.substr(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace BookManager
|
||||
39
src/util/BookManager.h
Normal file
39
src/util/BookManager.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace BookManager {
|
||||
|
||||
// Compute the cache directory path for a book (e.g. "/.crosspoint/epub_12345")
|
||||
std::string getBookCachePath(const std::string& bookPath);
|
||||
|
||||
// Move a book to /.archive/ preserving directory structure.
|
||||
// Renames the cache dir to match the new path hash. Removes from recents.
|
||||
// Returns true on success.
|
||||
bool archiveBook(const std::string& bookPath);
|
||||
|
||||
// Move a book from /.archive/ back to its original location.
|
||||
// Falls back to "/" if the original directory no longer exists.
|
||||
// Renames the cache dir to match the restored path hash. Returns true on success.
|
||||
// If unarchivedPath is non-null, stores the destination path on success.
|
||||
bool unarchiveBook(const std::string& archivePath, std::string* unarchivedPath = nullptr);
|
||||
|
||||
// Delete a book file, its cache directory, and remove from recents.
|
||||
bool deleteBook(const std::string& bookPath);
|
||||
|
||||
// Delete only the cache directory for a book and remove from recents.
|
||||
bool deleteBookCache(const std::string& bookPath);
|
||||
|
||||
// Clear indexed data from cache, preserving progress.
|
||||
// If alsoRegenerateCovers is true, also deletes cover/thumbnail BMPs.
|
||||
// Removes from recents.
|
||||
bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers);
|
||||
|
||||
// Returns true if the book path is inside the /.archive/ folder.
|
||||
bool isArchived(const std::string& bookPath);
|
||||
|
||||
// Remove empty directories under /.archive/ walking up from the book's parent.
|
||||
// Stops at /.archive itself (never removes it).
|
||||
void cleanupEmptyArchiveDirs(const std::string& bookPath);
|
||||
|
||||
} // namespace BookManager
|
||||
60
src/util/BookSettings.cpp
Normal file
60
src/util/BookSettings.cpp
Normal file
@@ -0,0 +1,60 @@
|
||||
#include "BookSettings.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t BOOK_SETTINGS_VERSION = 1;
|
||||
constexpr uint8_t BOOK_SETTINGS_COUNT = 1; // Number of persisted fields
|
||||
} // namespace
|
||||
|
||||
std::string BookSettings::filePath(const std::string& cachePath) { return cachePath + "/book_settings.bin"; }
|
||||
|
||||
BookSettings BookSettings::load(const std::string& cachePath) {
|
||||
BookSettings settings;
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("BST", filePath(cachePath), f)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
uint8_t version;
|
||||
serialization::readPod(f, version);
|
||||
if (version != BOOK_SETTINGS_VERSION) {
|
||||
f.close();
|
||||
return settings;
|
||||
}
|
||||
|
||||
uint8_t fieldCount;
|
||||
serialization::readPod(f, fieldCount);
|
||||
|
||||
// Read fields that exist (supports older files with fewer fields)
|
||||
uint8_t fieldsRead = 0;
|
||||
do {
|
||||
serialization::readPod(f, settings.letterboxFillOverride);
|
||||
if (++fieldsRead >= fieldCount) break;
|
||||
// New fields added here for forward compatibility
|
||||
} while (false);
|
||||
|
||||
f.close();
|
||||
LOG_DBG("BST", "Loaded book settings from %s (letterboxFill=%d)", filePath(cachePath).c_str(),
|
||||
settings.letterboxFillOverride);
|
||||
return settings;
|
||||
}
|
||||
|
||||
bool BookSettings::save(const std::string& cachePath, const BookSettings& settings) {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("BST", filePath(cachePath), f)) {
|
||||
LOG_ERR("BST", "Could not save book settings!");
|
||||
return false;
|
||||
}
|
||||
|
||||
serialization::writePod(f, BOOK_SETTINGS_VERSION);
|
||||
serialization::writePod(f, BOOK_SETTINGS_COUNT);
|
||||
serialization::writePod(f, settings.letterboxFillOverride);
|
||||
// New fields added here
|
||||
f.close();
|
||||
|
||||
LOG_DBG("BST", "Saved book settings to %s", filePath(cachePath).c_str());
|
||||
return true;
|
||||
}
|
||||
31
src/util/BookSettings.h
Normal file
31
src/util/BookSettings.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
// Per-book settings stored in the book's cache directory.
|
||||
// Fields default to sentinel values (0xFF) meaning "use global setting".
|
||||
class BookSettings {
|
||||
public:
|
||||
// 0xFF = use global default; otherwise one of SLEEP_SCREEN_LETTERBOX_FILL values (0-2).
|
||||
uint8_t letterboxFillOverride = USE_GLOBAL;
|
||||
|
||||
static constexpr uint8_t USE_GLOBAL = 0xFF;
|
||||
|
||||
// Returns the effective letterbox fill mode: the per-book override if set,
|
||||
// otherwise the global setting from CrossPointSettings.
|
||||
uint8_t getEffectiveLetterboxFill() const {
|
||||
if (letterboxFillOverride != USE_GLOBAL &&
|
||||
letterboxFillOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) {
|
||||
return letterboxFillOverride;
|
||||
}
|
||||
return SETTINGS.sleepScreenLetterboxFill;
|
||||
}
|
||||
|
||||
static BookSettings load(const std::string& cachePath);
|
||||
static bool save(const std::string& cachePath, const BookSettings& settings);
|
||||
|
||||
private:
|
||||
static std::string filePath(const std::string& cachePath);
|
||||
};
|
||||
158
src/util/BookmarkStore.cpp
Normal file
158
src/util/BookmarkStore.cpp
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "BookmarkStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; }
|
||||
|
||||
std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
|
||||
std::vector<Bookmark> bookmarks;
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("BKM", filePath(cachePath), f)) {
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
// File format v2: [version(1)] [count(2)] [entries...]
|
||||
// Each entry: [spine(2)] [page(2)] [snippetLen(1)] [snippet(snippetLen)]
|
||||
// v1 (no version byte): [count(2)] [entries of 4 bytes each]
|
||||
// We detect v1 by checking if the first byte could be a version marker (0xFF).
|
||||
|
||||
uint8_t firstByte;
|
||||
if (f.read(&firstByte, 1) != 1) {
|
||||
f.close();
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
uint16_t count;
|
||||
bool hasSnippets;
|
||||
|
||||
if (firstByte == 0xFF) {
|
||||
// v2 format: version marker was 0xFF
|
||||
hasSnippets = true;
|
||||
uint8_t countBytes[2];
|
||||
if (f.read(countBytes, 2) != 2) {
|
||||
f.close();
|
||||
return bookmarks;
|
||||
}
|
||||
count = static_cast<uint16_t>(countBytes[0]) | (static_cast<uint16_t>(countBytes[1]) << 8);
|
||||
} else {
|
||||
// v1 format: first byte was part of the count
|
||||
hasSnippets = false;
|
||||
uint8_t secondByte;
|
||||
if (f.read(&secondByte, 1) != 1) {
|
||||
f.close();
|
||||
return bookmarks;
|
||||
}
|
||||
count = static_cast<uint16_t>(firstByte) | (static_cast<uint16_t>(secondByte) << 8);
|
||||
}
|
||||
|
||||
if (count > MAX_BOOKMARKS) {
|
||||
count = MAX_BOOKMARKS;
|
||||
}
|
||||
|
||||
for (uint16_t i = 0; i < count; i++) {
|
||||
uint8_t entry[4];
|
||||
if (f.read(entry, 4) != 4) break;
|
||||
Bookmark b;
|
||||
b.spineIndex = static_cast<int16_t>(static_cast<uint16_t>(entry[0]) | (static_cast<uint16_t>(entry[1]) << 8));
|
||||
b.pageNumber = static_cast<int16_t>(static_cast<uint16_t>(entry[2]) | (static_cast<uint16_t>(entry[3]) << 8));
|
||||
|
||||
if (hasSnippets) {
|
||||
uint8_t snippetLen;
|
||||
if (f.read(&snippetLen, 1) != 1) break;
|
||||
if (snippetLen > 0) {
|
||||
std::vector<uint8_t> buf(snippetLen);
|
||||
if (f.read(buf.data(), snippetLen) != snippetLen) break;
|
||||
b.snippet = std::string(buf.begin(), buf.end());
|
||||
}
|
||||
}
|
||||
|
||||
bookmarks.push_back(b);
|
||||
}
|
||||
|
||||
f.close();
|
||||
return bookmarks;
|
||||
}
|
||||
|
||||
bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) {
|
||||
LOG_ERR("BKM", "Could not save bookmarks!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write v2 format: version marker + count + entries with snippets
|
||||
uint8_t version = 0xFF;
|
||||
f.write(&version, 1);
|
||||
|
||||
uint16_t count = static_cast<uint16_t>(bookmarks.size());
|
||||
uint8_t header[2] = {static_cast<uint8_t>(count & 0xFF), static_cast<uint8_t>((count >> 8) & 0xFF)};
|
||||
f.write(header, 2);
|
||||
|
||||
for (const auto& b : bookmarks) {
|
||||
uint8_t entry[4];
|
||||
entry[0] = static_cast<uint8_t>(b.spineIndex & 0xFF);
|
||||
entry[1] = static_cast<uint8_t>((b.spineIndex >> 8) & 0xFF);
|
||||
entry[2] = static_cast<uint8_t>(b.pageNumber & 0xFF);
|
||||
entry[3] = static_cast<uint8_t>((b.pageNumber >> 8) & 0xFF);
|
||||
f.write(entry, 4);
|
||||
|
||||
// Write snippet: length byte + string data
|
||||
uint8_t snippetLen = static_cast<uint8_t>(std::min(static_cast<int>(b.snippet.size()), MAX_SNIPPET_LENGTH));
|
||||
f.write(&snippetLen, 1);
|
||||
if (snippetLen > 0) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(b.snippet.c_str()), snippetLen);
|
||||
}
|
||||
}
|
||||
|
||||
f.close();
|
||||
LOG_DBG("BKM", "Saved %d bookmarks", count);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BookmarkStore::addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet) {
|
||||
auto bookmarks = load(cachePath);
|
||||
|
||||
// Check for duplicate
|
||||
for (const auto& b : bookmarks) {
|
||||
if (b.spineIndex == spineIndex && b.pageNumber == page) {
|
||||
return true; // Already bookmarked
|
||||
}
|
||||
}
|
||||
|
||||
if (static_cast<int>(bookmarks.size()) >= MAX_BOOKMARKS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Bookmark b;
|
||||
b.spineIndex = static_cast<int16_t>(spineIndex);
|
||||
b.pageNumber = static_cast<int16_t>(page);
|
||||
b.snippet = snippet.substr(0, MAX_SNIPPET_LENGTH);
|
||||
bookmarks.push_back(b);
|
||||
|
||||
return save(cachePath, bookmarks);
|
||||
}
|
||||
|
||||
bool BookmarkStore::removeBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||
auto bookmarks = load(cachePath);
|
||||
|
||||
auto it = std::remove_if(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) {
|
||||
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||
});
|
||||
|
||||
if (it == bookmarks.end()) {
|
||||
return false; // Not found
|
||||
}
|
||||
|
||||
bookmarks.erase(it, bookmarks.end());
|
||||
return save(cachePath, bookmarks);
|
||||
}
|
||||
|
||||
bool BookmarkStore::hasBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||
auto bookmarks = load(cachePath);
|
||||
return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) {
|
||||
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||
});
|
||||
}
|
||||
24
src/util/BookmarkStore.h
Normal file
24
src/util/BookmarkStore.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct Bookmark {
|
||||
int16_t spineIndex;
|
||||
int16_t pageNumber;
|
||||
std::string snippet; // First sentence or text excerpt from the page
|
||||
};
|
||||
|
||||
class BookmarkStore {
|
||||
public:
|
||||
static std::vector<Bookmark> load(const std::string& cachePath);
|
||||
static bool save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks);
|
||||
static bool addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet = "");
|
||||
static bool removeBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||
static bool hasBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||
|
||||
private:
|
||||
static std::string filePath(const std::string& cachePath);
|
||||
static constexpr int MAX_BOOKMARKS = 200;
|
||||
static constexpr int MAX_SNIPPET_LENGTH = 120;
|
||||
};
|
||||
163
src/util/BootNtpSync.cpp
Normal file
163
src/util/BootNtpSync.cpp
Normal file
@@ -0,0 +1,163 @@
|
||||
#include "BootNtpSync.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
#include "util/TimeSync.h"
|
||||
|
||||
namespace BootNtpSync {
|
||||
|
||||
static volatile bool running = false;
|
||||
static TaskHandle_t taskHandle = nullptr;
|
||||
|
||||
struct TaskParams {
|
||||
std::vector<WifiCredential> credentials;
|
||||
std::string lastConnectedSsid;
|
||||
};
|
||||
|
||||
static bool tryConnectToSavedNetwork(const TaskParams& params) {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.disconnect();
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
|
||||
LOG_DBG("BNTP", "Scanning WiFi networks...");
|
||||
int16_t count = WiFi.scanNetworks();
|
||||
if (count <= 0) {
|
||||
LOG_DBG("BNTP", "Scan returned %d networks", count);
|
||||
WiFi.scanDelete();
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("BNTP", "Found %d networks, matching against %zu saved credentials", count, params.credentials.size());
|
||||
|
||||
// Find best match: prefer lastConnectedSsid, otherwise first saved match
|
||||
const WifiCredential* bestMatch = nullptr;
|
||||
for (int i = 0; i < count; i++) {
|
||||
std::string ssid = WiFi.SSID(i).c_str();
|
||||
for (const auto& cred : params.credentials) {
|
||||
if (cred.ssid == ssid) {
|
||||
if (!bestMatch || cred.ssid == params.lastConnectedSsid) {
|
||||
bestMatch = &cred;
|
||||
}
|
||||
if (cred.ssid == params.lastConnectedSsid) {
|
||||
break; // Can't do better than lastConnected
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestMatch && bestMatch->ssid == params.lastConnectedSsid) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
WiFi.scanDelete();
|
||||
|
||||
if (!bestMatch) {
|
||||
LOG_DBG("BNTP", "No saved network found in scan results");
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DBG("BNTP", "Connecting to %s", bestMatch->ssid.c_str());
|
||||
if (!bestMatch->password.empty()) {
|
||||
WiFi.begin(bestMatch->ssid.c_str(), bestMatch->password.c_str());
|
||||
} else {
|
||||
WiFi.begin(bestMatch->ssid.c_str());
|
||||
}
|
||||
|
||||
const unsigned long start = millis();
|
||||
constexpr unsigned long CONNECT_TIMEOUT_MS = 10000;
|
||||
while (WiFi.status() != WL_CONNECTED && millis() - start < CONNECT_TIMEOUT_MS) {
|
||||
if (!running) return false;
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
|
||||
wl_status_t status = WiFi.status();
|
||||
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
|
||||
LOG_DBG("BNTP", "Connection failed (status=%d)", status);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
LOG_DBG("BNTP", "Connected to %s", bestMatch->ssid.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_DBG("BNTP", "Connection timed out");
|
||||
return false;
|
||||
}
|
||||
|
||||
static void taskFunc(void* param) {
|
||||
auto* params = static_cast<TaskParams*>(param);
|
||||
|
||||
bool connected = tryConnectToSavedNetwork(*params);
|
||||
|
||||
if (!connected && running) {
|
||||
LOG_DBG("BNTP", "First scan failed, retrying in 3s...");
|
||||
vTaskDelay(3000 / portTICK_PERIOD_MS);
|
||||
if (running) {
|
||||
connected = tryConnectToSavedNetwork(*params);
|
||||
}
|
||||
}
|
||||
|
||||
if (connected && running) {
|
||||
LOG_DBG("BNTP", "Starting NTP sync...");
|
||||
bool synced = TimeSync::waitForNtpSync(5000);
|
||||
TimeSync::stopNtpSync();
|
||||
if (synced) {
|
||||
LOG_DBG("BNTP", "NTP sync successful");
|
||||
} else {
|
||||
LOG_DBG("BNTP", "NTP sync timed out, continuing without time");
|
||||
}
|
||||
}
|
||||
|
||||
WiFi.disconnect(false);
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
vTaskDelay(100 / portTICK_PERIOD_MS);
|
||||
|
||||
delete params;
|
||||
running = false;
|
||||
taskHandle = nullptr;
|
||||
LOG_DBG("BNTP", "Boot NTP task complete");
|
||||
vTaskDelete(nullptr);
|
||||
}
|
||||
|
||||
void start() {
|
||||
if (!SETTINGS.autoNtpSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
WIFI_STORE.loadFromFile();
|
||||
const auto& creds = WIFI_STORE.getCredentials();
|
||||
if (creds.empty()) {
|
||||
LOG_DBG("BNTP", "No saved WiFi credentials, skipping boot NTP sync");
|
||||
return;
|
||||
}
|
||||
|
||||
auto* params = new TaskParams{creds, WIFI_STORE.getLastConnectedSsid()};
|
||||
|
||||
running = true;
|
||||
xTaskCreate(taskFunc, "BootNTP", 4096, params, 1, &taskHandle);
|
||||
LOG_DBG("BNTP", "Boot NTP sync task started");
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
if (!running) return;
|
||||
LOG_DBG("BNTP", "Cancelling boot NTP sync...");
|
||||
running = false;
|
||||
// Wait for the task to notice and clean up (up to 2s)
|
||||
for (int i = 0; i < 20 && taskHandle != nullptr; i++) {
|
||||
delay(100);
|
||||
}
|
||||
LOG_DBG("BNTP", "Boot NTP sync cancelled");
|
||||
}
|
||||
|
||||
bool isRunning() { return running; }
|
||||
|
||||
} // namespace BootNtpSync
|
||||
16
src/util/BootNtpSync.h
Normal file
16
src/util/BootNtpSync.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
namespace BootNtpSync {
|
||||
|
||||
// Spawn a background FreeRTOS task that scans for saved WiFi networks,
|
||||
// connects, syncs NTP, then tears down WiFi. Non-blocking; does nothing
|
||||
// if autoNtpSync is disabled or no credentials are stored.
|
||||
void start();
|
||||
|
||||
// Signal the background task to abort and wait for it to finish.
|
||||
// Call before starting any other WiFi operation.
|
||||
void cancel();
|
||||
|
||||
bool isRunning();
|
||||
|
||||
} // namespace BootNtpSync
|
||||
589
src/util/Dictionary.cpp
Normal file
589
src/util/Dictionary.cpp
Normal file
@@ -0,0 +1,589 @@
|
||||
#include "Dictionary.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
constexpr const char* IDX_PATH = "/.dictionary/dictionary.idx";
|
||||
constexpr const char* DICT_PATH = "/.dictionary/dictionary.dict";
|
||||
constexpr const char* CACHE_PATH = "/.dictionary/dictionary.cache";
|
||||
constexpr uint32_t CACHE_MAGIC = 0x44494358; // "DICX"
|
||||
|
||||
// g_ascii_strcasecmp equivalent: compare lowercasing only ASCII A-Z.
|
||||
int asciiCaseCmp(const char* s1, const char* s2) {
|
||||
const auto* p1 = reinterpret_cast<const unsigned char*>(s1);
|
||||
const auto* p2 = reinterpret_cast<const unsigned char*>(s2);
|
||||
while (*p1 && *p2) {
|
||||
unsigned char c1 = *p1, c2 = *p2;
|
||||
if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
|
||||
if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
|
||||
if (c1 != c2) return static_cast<int>(c1) - static_cast<int>(c2);
|
||||
++p1;
|
||||
++p2;
|
||||
}
|
||||
return static_cast<int>(*p1) - static_cast<int>(*p2);
|
||||
}
|
||||
|
||||
// StarDict index comparison: case-insensitive first, then case-sensitive tiebreaker.
|
||||
// This matches the stardict_strcmp used by StarDict to sort .idx entries.
|
||||
int stardictCmp(const char* s1, const char* s2) {
|
||||
int ci = asciiCaseCmp(s1, s2);
|
||||
if (ci != 0) return ci;
|
||||
return std::strcmp(s1, s2);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::vector<uint32_t> Dictionary::sparseOffsets;
|
||||
uint32_t Dictionary::totalWords = 0;
|
||||
bool Dictionary::indexLoaded = false;
|
||||
|
||||
bool Dictionary::exists() { return Storage.exists(IDX_PATH); }
|
||||
|
||||
bool Dictionary::cacheExists() { return Storage.exists(CACHE_PATH); }
|
||||
|
||||
void Dictionary::deleteCache() {
|
||||
Storage.remove(CACHE_PATH);
|
||||
// Reset in-memory state so next lookup rebuilds from the .idx file.
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
indexLoaded = false;
|
||||
}
|
||||
|
||||
std::string Dictionary::cleanWord(const std::string& word) {
|
||||
if (word.empty()) return "";
|
||||
|
||||
// Find first alphanumeric character
|
||||
size_t start = 0;
|
||||
while (start < word.size() && !std::isalnum(static_cast<unsigned char>(word[start]))) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// Find last alphanumeric character
|
||||
size_t end = word.size();
|
||||
while (end > start && !std::isalnum(static_cast<unsigned char>(word[end - 1]))) {
|
||||
end--;
|
||||
}
|
||||
|
||||
if (start >= end) return "";
|
||||
|
||||
std::string result = word.substr(start, end - start);
|
||||
// Lowercase
|
||||
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache: persists the sparse offset table to SD card so subsequent boots skip
|
||||
// the full .idx scan. The cache is invalidated when the .idx file size changes.
|
||||
//
|
||||
// Format: [magic 4B][idxFileSize 4B][totalWords 4B][count 4B][offsets N×4B]
|
||||
// All values are stored in native byte order (little-endian on ESP32).
|
||||
// ---------------------------------------------------------------------------
|
||||
bool Dictionary::loadCachedIndex() {
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||
const uint32_t idxFileSize = static_cast<uint32_t>(idx.fileSize());
|
||||
idx.close();
|
||||
|
||||
FsFile cache;
|
||||
if (!Storage.openFileForRead("DICT", CACHE_PATH, cache)) return false;
|
||||
|
||||
// Read and validate header
|
||||
uint32_t header[4]; // magic, idxFileSize, totalWords, count
|
||||
if (cache.read(reinterpret_cast<uint8_t*>(header), 16) != 16) {
|
||||
cache.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (header[0] != CACHE_MAGIC || header[1] != idxFileSize) {
|
||||
cache.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
totalWords = header[2];
|
||||
const uint32_t count = header[3];
|
||||
|
||||
sparseOffsets.resize(count);
|
||||
const int bytesToRead = static_cast<int>(count * sizeof(uint32_t));
|
||||
if (cache.read(reinterpret_cast<uint8_t*>(sparseOffsets.data()), bytesToRead) != bytesToRead) {
|
||||
cache.close();
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
cache.close();
|
||||
indexLoaded = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void Dictionary::saveCachedIndex(uint32_t idxFileSize) {
|
||||
FsFile cache;
|
||||
if (!Storage.openFileForWrite("DICT", CACHE_PATH, cache)) return;
|
||||
|
||||
const uint32_t count = static_cast<uint32_t>(sparseOffsets.size());
|
||||
uint32_t header[4] = {CACHE_MAGIC, idxFileSize, totalWords, count};
|
||||
|
||||
cache.write(reinterpret_cast<const uint8_t*>(header), 16);
|
||||
cache.write(reinterpret_cast<const uint8_t*>(sparseOffsets.data()), count * sizeof(uint32_t));
|
||||
cache.close();
|
||||
}
|
||||
|
||||
// Scan the .idx file to build a sparse offset table for fast lookups.
|
||||
// Records the file offset of every SPARSE_INTERVAL-th entry.
|
||||
bool Dictionary::loadIndex(const std::function<void(int percent)>& onProgress,
|
||||
const std::function<bool()>& shouldCancel) {
|
||||
// Try loading from cache first (nearly instant)
|
||||
if (loadCachedIndex()) return true;
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||
|
||||
const uint32_t fileSize = static_cast<uint32_t>(idx.fileSize());
|
||||
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
|
||||
uint32_t pos = 0;
|
||||
int lastReportedPercent = -1;
|
||||
|
||||
while (pos < fileSize) {
|
||||
if (shouldCancel && (totalWords % 100 == 0) && shouldCancel()) {
|
||||
idx.close();
|
||||
sparseOffsets.clear();
|
||||
totalWords = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (totalWords % SPARSE_INTERVAL == 0) {
|
||||
sparseOffsets.push_back(pos);
|
||||
}
|
||||
|
||||
// Skip word (read until null terminator)
|
||||
int ch;
|
||||
do {
|
||||
ch = idx.read();
|
||||
if (ch < 0) {
|
||||
pos = fileSize;
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
} while (ch != 0);
|
||||
|
||||
if (pos >= fileSize) break;
|
||||
|
||||
// Skip 8 bytes (4-byte offset + 4-byte size)
|
||||
uint8_t skip[8];
|
||||
if (idx.read(skip, 8) != 8) break;
|
||||
pos += 8;
|
||||
|
||||
totalWords++;
|
||||
|
||||
if (onProgress && fileSize > 0) {
|
||||
int percent = static_cast<int>(static_cast<uint64_t>(pos) * 90 / fileSize);
|
||||
if (percent > lastReportedPercent + 4) {
|
||||
lastReportedPercent = percent;
|
||||
onProgress(percent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
indexLoaded = true;
|
||||
|
||||
// Persist to cache so next boot is instant
|
||||
if (totalWords > 0) saveCachedIndex(fileSize);
|
||||
|
||||
return totalWords > 0;
|
||||
}
|
||||
|
||||
// Read a null-terminated word string from the current file position.
|
||||
std::string Dictionary::readWord(FsFile& file) {
|
||||
std::string word;
|
||||
while (true) {
|
||||
int ch = file.read();
|
||||
if (ch <= 0) break; // null terminator (0) or error (-1)
|
||||
word += static_cast<char>(ch);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// Read a definition from the .dict file at the given offset and size.
|
||||
std::string Dictionary::readDefinition(uint32_t offset, uint32_t size) {
|
||||
FsFile dict;
|
||||
if (!Storage.openFileForRead("DICT", DICT_PATH, dict)) return "";
|
||||
|
||||
dict.seekSet(offset);
|
||||
|
||||
std::string def(size, '\0');
|
||||
int bytesRead = dict.read(reinterpret_cast<uint8_t*>(&def[0]), size);
|
||||
dict.close();
|
||||
|
||||
if (bytesRead < 0) return "";
|
||||
if (static_cast<uint32_t>(bytesRead) < size) def.resize(bytesRead);
|
||||
return def;
|
||||
}
|
||||
|
||||
// Binary search the sparse offset table, then linear scan within the matching segment.
|
||||
// Uses StarDict's sort order: case-insensitive first, then case-sensitive tiebreaker.
|
||||
// The exact match is case-insensitive so e.g. "simple" matches "Simple".
|
||||
std::string Dictionary::searchIndex(const std::string& word, const std::function<bool()>& shouldCancel) {
|
||||
if (sparseOffsets.empty()) return "";
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return "";
|
||||
|
||||
// Binary search the sparse offset table to find the right segment.
|
||||
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
|
||||
|
||||
while (lo < hi) {
|
||||
if (shouldCancel && shouldCancel()) {
|
||||
idx.close();
|
||||
return "";
|
||||
}
|
||||
|
||||
int mid = lo + (hi - lo + 1) / 2;
|
||||
idx.seekSet(sparseOffsets[mid]);
|
||||
std::string key = readWord(idx);
|
||||
|
||||
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Linear scan within the segment starting at sparseOffsets[lo].
|
||||
idx.seekSet(sparseOffsets[lo]);
|
||||
|
||||
int maxEntries = SPARSE_INTERVAL;
|
||||
if (lo == static_cast<int>(sparseOffsets.size()) - 1) {
|
||||
maxEntries = static_cast<int>(totalWords - static_cast<uint32_t>(lo) * SPARSE_INTERVAL);
|
||||
}
|
||||
|
||||
// Scan entries, preferring an exact case-sensitive match over a case-insensitive one.
|
||||
// In stardict order, all case variants of a word are adjacent (e.g. "Professor" then "professor"),
|
||||
// and they may have different definitions. We want the lowercase entry when the user searched
|
||||
// for a lowercase word, falling back to any case variant.
|
||||
uint32_t bestOffset = 0, bestSize = 0;
|
||||
bool found = false;
|
||||
|
||||
for (int i = 0; i < maxEntries; i++) {
|
||||
if (shouldCancel && shouldCancel()) {
|
||||
idx.close();
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string key = readWord(idx);
|
||||
if (key.empty()) break;
|
||||
|
||||
// Read offset and size (4 bytes each, big-endian)
|
||||
uint8_t buf[8];
|
||||
if (idx.read(buf, 8) != 8) break;
|
||||
|
||||
uint32_t dictOffset = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
|
||||
(static_cast<uint32_t>(buf[2]) << 8) | static_cast<uint32_t>(buf[3]);
|
||||
uint32_t dictSize = (static_cast<uint32_t>(buf[4]) << 24) | (static_cast<uint32_t>(buf[5]) << 16) |
|
||||
(static_cast<uint32_t>(buf[6]) << 8) | static_cast<uint32_t>(buf[7]);
|
||||
|
||||
if (asciiCaseCmp(key.c_str(), word.c_str()) == 0) {
|
||||
// Case-insensitive match — remember the first one as fallback
|
||||
if (!found) {
|
||||
bestOffset = dictOffset;
|
||||
bestSize = dictSize;
|
||||
found = true;
|
||||
}
|
||||
// Exact case-sensitive match — use immediately
|
||||
if (key == word) {
|
||||
idx.close();
|
||||
return readDefinition(dictOffset, dictSize);
|
||||
}
|
||||
} else if (found) {
|
||||
// We've moved past all case variants of this word — stop
|
||||
break;
|
||||
} else if (stardictCmp(key.c_str(), word.c_str()) > 0) {
|
||||
// Past the target in StarDict sort order — stop scanning
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
return found ? readDefinition(bestOffset, bestSize) : "";
|
||||
}
|
||||
|
||||
std::string Dictionary::lookup(const std::string& word, const std::function<void(int percent)>& onProgress,
|
||||
const std::function<bool()>& shouldCancel) {
|
||||
if (!indexLoaded) {
|
||||
if (!loadIndex(onProgress, shouldCancel)) return "";
|
||||
}
|
||||
|
||||
// searchIndex uses StarDict sort order + case-insensitive match,
|
||||
// so a single pass handles all casing variants.
|
||||
std::string result = searchIndex(word, shouldCancel);
|
||||
if (onProgress) onProgress(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> Dictionary::getStemVariants(const std::string& word) {
|
||||
std::vector<std::string> variants;
|
||||
size_t len = word.size();
|
||||
if (len < 3) return variants;
|
||||
|
||||
auto endsWith = [&word, len](const char* suffix) {
|
||||
size_t slen = strlen(suffix);
|
||||
return len >= slen && word.compare(len - slen, slen, suffix) == 0;
|
||||
};
|
||||
|
||||
auto add = [&variants](const std::string& s) {
|
||||
if (s.size() >= 2) variants.push_back(s);
|
||||
};
|
||||
|
||||
// Plurals (longer suffixes first to avoid partial matches)
|
||||
if (endsWith("sses")) add(word.substr(0, len - 2));
|
||||
if (endsWith("ses")) add(word.substr(0, len - 2) + "is"); // analyses -> analysis
|
||||
if (endsWith("ies")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
add(word.substr(0, len - 2)); // dies -> die, ties -> tie
|
||||
}
|
||||
if (endsWith("ves")) {
|
||||
add(word.substr(0, len - 3) + "f"); // wolves -> wolf
|
||||
add(word.substr(0, len - 3) + "fe"); // knives -> knife
|
||||
add(word.substr(0, len - 1)); // misgives -> misgive
|
||||
}
|
||||
if (endsWith("men")) add(word.substr(0, len - 3) + "man"); // firemen -> fireman
|
||||
if (endsWith("es") && !endsWith("sses") && !endsWith("ies") && !endsWith("ves")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
if (endsWith("s") && !endsWith("ss") && !endsWith("us") && !endsWith("es")) {
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
|
||||
// Past tense
|
||||
if (endsWith("ied")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
add(word.substr(0, len - 1));
|
||||
}
|
||||
if (endsWith("ed") && !endsWith("ied")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
if (len > 4 && word[len - 3] == word[len - 4]) {
|
||||
add(word.substr(0, len - 3));
|
||||
}
|
||||
}
|
||||
|
||||
// Progressive
|
||||
if (endsWith("ying")) {
|
||||
add(word.substr(0, len - 4) + "ie");
|
||||
}
|
||||
if (endsWith("ing") && !endsWith("ying")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
if (len > 5 && word[len - 4] == word[len - 5]) {
|
||||
add(word.substr(0, len - 4));
|
||||
}
|
||||
}
|
||||
|
||||
// Adverb
|
||||
if (endsWith("ically")) {
|
||||
add(word.substr(0, len - 6) + "ic"); // historically -> historic
|
||||
add(word.substr(0, len - 4)); // basically -> basic
|
||||
}
|
||||
if (endsWith("ally") && !endsWith("ically")) {
|
||||
add(word.substr(0, len - 4) + "al"); // accidentally -> accidental
|
||||
add(word.substr(0, len - 2)); // naturally -> natur... (fallback to -ly strip)
|
||||
}
|
||||
if (endsWith("ily") && !endsWith("ally")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
}
|
||||
if (endsWith("ly") && !endsWith("ily") && !endsWith("ally")) {
|
||||
add(word.substr(0, len - 2));
|
||||
}
|
||||
|
||||
// Comparative / superlative
|
||||
if (endsWith("ier")) {
|
||||
add(word.substr(0, len - 3) + "y");
|
||||
}
|
||||
if (endsWith("er") && !endsWith("ier")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 1));
|
||||
if (len > 4 && word[len - 3] == word[len - 4]) {
|
||||
add(word.substr(0, len - 3));
|
||||
}
|
||||
}
|
||||
if (endsWith("iest")) {
|
||||
add(word.substr(0, len - 4) + "y");
|
||||
}
|
||||
if (endsWith("est") && !endsWith("iest")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 2));
|
||||
if (len > 5 && word[len - 4] == word[len - 5]) {
|
||||
add(word.substr(0, len - 4));
|
||||
}
|
||||
}
|
||||
|
||||
// Derivational suffixes
|
||||
if (endsWith("ness")) add(word.substr(0, len - 4));
|
||||
if (endsWith("ment")) add(word.substr(0, len - 4));
|
||||
if (endsWith("ful")) add(word.substr(0, len - 3));
|
||||
if (endsWith("less")) add(word.substr(0, len - 4));
|
||||
if (endsWith("able")) {
|
||||
add(word.substr(0, len - 4));
|
||||
add(word.substr(0, len - 4) + "e");
|
||||
}
|
||||
if (endsWith("ible")) {
|
||||
add(word.substr(0, len - 4));
|
||||
add(word.substr(0, len - 4) + "e");
|
||||
}
|
||||
if (endsWith("ation")) {
|
||||
add(word.substr(0, len - 5)); // information -> inform
|
||||
add(word.substr(0, len - 5) + "e"); // exploration -> explore
|
||||
add(word.substr(0, len - 5) + "ate"); // donation -> donate
|
||||
}
|
||||
if (endsWith("tion") && !endsWith("ation")) {
|
||||
add(word.substr(0, len - 4) + "te"); // completion -> complete
|
||||
add(word.substr(0, len - 3)); // action -> act
|
||||
add(word.substr(0, len - 3) + "e"); // reduction -> reduce
|
||||
}
|
||||
if (endsWith("ion") && !endsWith("tion")) {
|
||||
add(word.substr(0, len - 3)); // revision -> revis (-> revise via +e)
|
||||
add(word.substr(0, len - 3) + "e"); // revision -> revise
|
||||
}
|
||||
if (endsWith("al") && !endsWith("ial")) {
|
||||
add(word.substr(0, len - 2));
|
||||
add(word.substr(0, len - 2) + "e");
|
||||
}
|
||||
if (endsWith("ial")) {
|
||||
add(word.substr(0, len - 3));
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("ous")) {
|
||||
add(word.substr(0, len - 3)); // dangerous -> danger
|
||||
add(word.substr(0, len - 3) + "e"); // famous -> fame
|
||||
}
|
||||
if (endsWith("ive")) {
|
||||
add(word.substr(0, len - 3)); // active -> act
|
||||
add(word.substr(0, len - 3) + "e"); // creative -> create
|
||||
}
|
||||
if (endsWith("ize")) {
|
||||
add(word.substr(0, len - 3)); // modernize -> modern
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("ise")) {
|
||||
add(word.substr(0, len - 3)); // advertise -> advert
|
||||
add(word.substr(0, len - 3) + "e");
|
||||
}
|
||||
if (endsWith("en")) {
|
||||
add(word.substr(0, len - 2)); // darken -> dark
|
||||
add(word.substr(0, len - 2) + "e"); // widen -> wide
|
||||
}
|
||||
|
||||
// Prefix removal
|
||||
if (len > 5 && word.compare(0, 2, "un") == 0) add(word.substr(2));
|
||||
if (len > 6 && word.compare(0, 3, "dis") == 0) add(word.substr(3));
|
||||
if (len > 6 && word.compare(0, 3, "mis") == 0) add(word.substr(3));
|
||||
if (len > 6 && word.compare(0, 3, "pre") == 0) add(word.substr(3));
|
||||
if (len > 7 && word.compare(0, 4, "over") == 0) add(word.substr(4));
|
||||
if (len > 5 && word.compare(0, 2, "re") == 0) add(word.substr(2));
|
||||
|
||||
// Deduplicate while preserving insertion order (inflectional stems first, prefixes last)
|
||||
std::vector<std::string> deduped;
|
||||
for (const auto& v : variants) {
|
||||
if (std::find(deduped.begin(), deduped.end(), v) != deduped.end()) continue;
|
||||
// cppcheck-suppress useStlAlgorithm
|
||||
deduped.push_back(v);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
int Dictionary::editDistance(const std::string& a, const std::string& b, int maxDist) {
|
||||
int m = static_cast<int>(a.size());
|
||||
int n = static_cast<int>(b.size());
|
||||
if (std::abs(m - n) > maxDist) return maxDist + 1;
|
||||
|
||||
std::vector<int> dp(n + 1);
|
||||
for (int j = 0; j <= n; j++) dp[j] = j;
|
||||
|
||||
for (int i = 1; i <= m; i++) {
|
||||
int prev = dp[0];
|
||||
dp[0] = i;
|
||||
int rowMin = dp[0];
|
||||
for (int j = 1; j <= n; j++) {
|
||||
int temp = dp[j];
|
||||
if (a[i - 1] == b[j - 1]) {
|
||||
dp[j] = prev;
|
||||
} else {
|
||||
dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]});
|
||||
}
|
||||
prev = temp;
|
||||
if (dp[j] < rowMin) rowMin = dp[j];
|
||||
}
|
||||
if (rowMin > maxDist) return maxDist + 1;
|
||||
}
|
||||
return dp[n];
|
||||
}
|
||||
|
||||
std::vector<std::string> Dictionary::findSimilar(const std::string& word, int maxResults) {
|
||||
if (!indexLoaded || sparseOffsets.empty()) return {};
|
||||
|
||||
FsFile idx;
|
||||
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return {};
|
||||
|
||||
// Binary search to find the segment containing or nearest to the word
|
||||
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
|
||||
while (lo < hi) {
|
||||
int mid = lo + (hi - lo + 1) / 2;
|
||||
idx.seekSet(sparseOffsets[mid]);
|
||||
std::string key = readWord(idx);
|
||||
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan entries from the segment before through the segment after the target
|
||||
int startSeg = std::max(0, lo - 1);
|
||||
int endSeg = std::min(static_cast<int>(sparseOffsets.size()) - 1, lo + 1);
|
||||
idx.seekSet(sparseOffsets[startSeg]);
|
||||
|
||||
int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL;
|
||||
int remaining = static_cast<int>(totalWords) - startSeg * SPARSE_INTERVAL;
|
||||
if (totalToScan > remaining) totalToScan = remaining;
|
||||
|
||||
int maxDist = std::max(2, static_cast<int>(word.size()) / 3 + 1);
|
||||
|
||||
struct Candidate {
|
||||
std::string text;
|
||||
int distance;
|
||||
};
|
||||
std::vector<Candidate> candidates;
|
||||
|
||||
for (int i = 0; i < totalToScan; i++) {
|
||||
std::string key = readWord(idx);
|
||||
if (key.empty()) break;
|
||||
|
||||
uint8_t skip[8];
|
||||
if (idx.read(skip, 8) != 8) break;
|
||||
|
||||
if (key == word) continue;
|
||||
int dist = editDistance(key, word, maxDist);
|
||||
if (dist <= maxDist) {
|
||||
candidates.push_back({key, dist});
|
||||
}
|
||||
}
|
||||
|
||||
idx.close();
|
||||
|
||||
std::sort(candidates.begin(), candidates.end(),
|
||||
[](const Candidate& a, const Candidate& b) { return a.distance < b.distance; });
|
||||
|
||||
std::vector<std::string> results;
|
||||
for (size_t i = 0; i < candidates.size() && static_cast<int>(results.size()) < maxResults; i++) {
|
||||
results.push_back(candidates[i].text);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
34
src/util/Dictionary.h
Normal file
34
src/util/Dictionary.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class FsFile;
|
||||
|
||||
class Dictionary {
|
||||
public:
|
||||
static bool exists();
|
||||
static bool cacheExists();
|
||||
static void deleteCache();
|
||||
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
|
||||
const std::function<bool()>& shouldCancel = nullptr);
|
||||
static std::string cleanWord(const std::string& word);
|
||||
static std::vector<std::string> getStemVariants(const std::string& word);
|
||||
static std::vector<std::string> findSimilar(const std::string& word, int maxResults = 6);
|
||||
|
||||
private:
|
||||
static constexpr int SPARSE_INTERVAL = 512;
|
||||
|
||||
static std::vector<uint32_t> sparseOffsets;
|
||||
static uint32_t totalWords;
|
||||
static bool indexLoaded;
|
||||
|
||||
static bool loadIndex(const std::function<void(int percent)>& onProgress, const std::function<bool()>& shouldCancel);
|
||||
static bool loadCachedIndex();
|
||||
static void saveCachedIndex(uint32_t idxFileSize);
|
||||
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
|
||||
static std::string readWord(FsFile& file);
|
||||
static std::string readDefinition(uint32_t offset, uint32_t size);
|
||||
static int editDistance(const std::string& a, const std::string& b, int maxDist);
|
||||
};
|
||||
88
src/util/LookupHistory.cpp
Normal file
88
src/util/LookupHistory.cpp
Normal file
@@ -0,0 +1,88 @@
|
||||
#include "LookupHistory.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
std::string LookupHistory::filePath(const std::string& cachePath) { return cachePath + "/lookups.txt"; }
|
||||
|
||||
bool LookupHistory::hasHistory(const std::string& cachePath) {
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||
return false;
|
||||
}
|
||||
bool nonEmpty = f.available() > 0;
|
||||
f.close();
|
||||
return nonEmpty;
|
||||
}
|
||||
|
||||
std::vector<std::string> LookupHistory::load(const std::string& cachePath) {
|
||||
std::vector<std::string> words;
|
||||
FsFile f;
|
||||
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||
return words;
|
||||
}
|
||||
|
||||
std::string line;
|
||||
while (f.available() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||
char c;
|
||||
if (f.read(reinterpret_cast<uint8_t*>(&c), 1) != 1) break;
|
||||
if (c == '\n') {
|
||||
if (!line.empty()) {
|
||||
words.push_back(line);
|
||||
line.clear();
|
||||
}
|
||||
} else {
|
||||
line += c;
|
||||
}
|
||||
}
|
||||
if (!line.empty() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||
words.push_back(line);
|
||||
}
|
||||
f.close();
|
||||
return words;
|
||||
}
|
||||
|
||||
void LookupHistory::removeWord(const std::string& cachePath, const std::string& word) {
|
||||
if (word.empty()) return;
|
||||
|
||||
auto existing = load(cachePath);
|
||||
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& w : existing) {
|
||||
if (w != word) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
|
||||
void LookupHistory::addWord(const std::string& cachePath, const std::string& word) {
|
||||
if (word.empty()) return;
|
||||
|
||||
// Check if already present
|
||||
auto existing = load(cachePath);
|
||||
if (std::any_of(existing.begin(), existing.end(), [&word](const std::string& w) { return w == word; })) return;
|
||||
|
||||
// Cap at max entries
|
||||
if (static_cast<int>(existing.size()) >= MAX_ENTRIES) return;
|
||||
|
||||
FsFile f;
|
||||
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewrite existing entries plus new one
|
||||
for (const auto& w : existing) {
|
||||
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
}
|
||||
f.write(reinterpret_cast<const uint8_t*>(word.c_str()), word.size());
|
||||
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||
f.close();
|
||||
}
|
||||
15
src/util/LookupHistory.h
Normal file
15
src/util/LookupHistory.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class LookupHistory {
|
||||
public:
|
||||
static std::vector<std::string> load(const std::string& cachePath);
|
||||
static void addWord(const std::string& cachePath, const std::string& word);
|
||||
static void removeWord(const std::string& cachePath, const std::string& word);
|
||||
static bool hasHistory(const std::string& cachePath);
|
||||
|
||||
private:
|
||||
static std::string filePath(const std::string& cachePath);
|
||||
static constexpr int MAX_ENTRIES = 500;
|
||||
};
|
||||
58
src/util/TimeSync.cpp
Normal file
58
src/util/TimeSync.cpp
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "TimeSync.h"
|
||||
|
||||
#include <Logging.h>
|
||||
#include <esp_sntp.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
|
||||
namespace TimeSync {
|
||||
|
||||
void startNtpSync() {
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
}
|
||||
|
||||
// Apply timezone so NTP-synced time is displayed correctly
|
||||
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
|
||||
tzset();
|
||||
|
||||
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
||||
esp_sntp_setservername(0, "pool.ntp.org");
|
||||
esp_sntp_init();
|
||||
|
||||
LOG_DBG("NTP", "SNTP service started");
|
||||
}
|
||||
|
||||
bool waitForNtpSync(int timeoutMs) {
|
||||
startNtpSync();
|
||||
|
||||
const int intervalMs = 100;
|
||||
const int maxRetries = timeoutMs / intervalMs;
|
||||
int retry = 0;
|
||||
|
||||
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
|
||||
vTaskDelay(intervalMs / portTICK_PERIOD_MS);
|
||||
retry++;
|
||||
}
|
||||
|
||||
if (retry < maxRetries) {
|
||||
LOG_DBG("NTP", "Time synced after %d ms", retry * intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG_DBG("NTP", "Sync timeout after %d ms", timeoutMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
void stopNtpSync() {
|
||||
if (esp_sntp_enabled()) {
|
||||
esp_sntp_stop();
|
||||
LOG_DBG("NTP", "SNTP service stopped");
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace TimeSync
|
||||
17
src/util/TimeSync.h
Normal file
17
src/util/TimeSync.h
Normal file
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
namespace TimeSync {
|
||||
|
||||
// Start NTP time synchronization (non-blocking).
|
||||
// Configures and starts the SNTP service; time will be updated
|
||||
// automatically when the NTP response arrives.
|
||||
void startNtpSync();
|
||||
|
||||
// Start NTP sync and block until complete or timeout.
|
||||
// Returns true if time was synced, false on timeout.
|
||||
bool waitForNtpSync(int timeoutMs = 5000);
|
||||
|
||||
// Stop the SNTP service. Call before disconnecting WiFi.
|
||||
void stopNtpSync();
|
||||
|
||||
} // namespace TimeSync
|
||||
Reference in New Issue
Block a user