feat: add SD card font support with on-device download and web management
Add a complete SD card font subsystem that enables users to install and use custom fonts beyond the three built-in families. This combines the back-end firmware support (#1327) with the font configuration, build pipeline, CI distribution, and user-facing management UI (#1392). Core font system: - Custom .cpfont binary format (v4) with multi-style support (regular, bold, italic, bold-italic) packed into a single file per size - On-demand glyph loading from SD card with two-pass prewarm rendering to bulk-read glyphs per page, achieving near-flash performance for Latin text (~697ms vs ~681ms) and viable CJK rendering (~32% slower) - Persistent advance cache for layout measurement without SD I/O - Overflow ring buffer for glyph cache misses during rendering - Memory-conscious design: only advance tables kept in RAM; glyph bitmaps, kern tables, and ligatures loaded on demand from SD Font management: - On-device WiFi download from GitHub Releases with manifest-based discovery, install/update detection, and progress UI - Web interface font upload, listing, and deletion via /fonts page - Manual SD card copy to /fonts/ or /.fonts/ directories - Font selection integrated into Settings > Reader > Font Family Build pipeline: - Declarative YAML config (sd-fonts.yaml) as single source of truth for the 17-family font library (serif, sans, mono, accessibility) - Python converter (fontconvert_sdcard.py) for TTF/OTF to .cpfont with FreeType rasterization, class-based kerning, and ligature extraction - Parallel build orchestrator with variable font instance extraction - CI workflow publishing versioned + stable releases to a dedicated crosspoint-fonts repository with auto-incrementing revision tags - Centralized version constants (cpfont_version.py) shared across build tooling and CI, with firmware headers as manual sync points Additional fixes: - CJK characters no longer get hyphens inserted at line breaks - Advance table eliminates 30+ second stalls during CJK section indexing for paragraphs with >512 unique codepoints Closes #930 Co-authored-by: Zach Nelson <zach@zdnelson.com> Co-authored-by: Justin <itsthisjustin@users.noreply.github.com> Co-authored-by: jpirnay <jens@pirnay.com> Co-authored-by: mcrosson <kemonine@kemonine.info>
This commit is contained in:
committed by
Zach Nelson
co-authored by
Zach Nelson
Justin
jpirnay
mcrosson
parent
29fd29f537
commit
7993b2bb97
@@ -0,0 +1,426 @@
|
||||
#include "FontDownloadActivity.h"
|
||||
|
||||
#include <ArduinoJson.h>
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Logging.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
|
||||
FontDownloadActivity::FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("FontDownload", renderer, mappedInput), fontInstaller_(sdFontSystem.registry()) {}
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
void FontDownloadActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
WiFi.mode(WIFI_STA);
|
||||
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); });
|
||||
}
|
||||
|
||||
void FontDownloadActivity::onExit() {
|
||||
Activity::onExit();
|
||||
WiFi.disconnect(false);
|
||||
delay(100);
|
||||
WiFi.mode(WIFI_OFF);
|
||||
delay(100);
|
||||
}
|
||||
|
||||
void FontDownloadActivity::onWifiSelectionComplete(const bool success) {
|
||||
if (!success) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = LOADING_MANIFEST;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fetchAndParseManifest()) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
selectedIndex_ = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Manifest fetching ---
|
||||
|
||||
bool FontDownloadActivity::fetchAndParseManifest() {
|
||||
// Download manifest to a temp file on SD card to avoid holding both
|
||||
// TLS buffers and the full JSON string in RAM simultaneously.
|
||||
static constexpr const char* MANIFEST_TMP = "/fonts_manifest.tmp";
|
||||
|
||||
auto result = HttpDownloader::downloadToFile(FONT_MANIFEST_URL, MANIFEST_TMP, nullptr);
|
||||
if (result != HttpDownloader::OK) {
|
||||
LOG_ERR("FONT", "Failed to fetch manifest from %s", FONT_MANIFEST_URL);
|
||||
errorMessage_ = "Failed to fetch font list";
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
return false;
|
||||
}
|
||||
|
||||
// HTTP client is now closed — TLS buffers freed. Parse JSON from file.
|
||||
FsFile manifestFile;
|
||||
if (!Storage.openFileForRead("FONT", MANIFEST_TMP, manifestFile)) {
|
||||
LOG_ERR("FONT", "Failed to open temp manifest");
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
errorMessage_ = "Failed to read font list";
|
||||
return false;
|
||||
}
|
||||
|
||||
JsonDocument doc;
|
||||
DeserializationError err = deserializeJson(doc, manifestFile);
|
||||
manifestFile.close();
|
||||
Storage.remove(MANIFEST_TMP);
|
||||
|
||||
if (err) {
|
||||
LOG_ERR("FONT", "Manifest parse error: %s", err.c_str());
|
||||
errorMessage_ = "Invalid font manifest";
|
||||
return false;
|
||||
}
|
||||
|
||||
int version = doc["version"] | 0;
|
||||
if (version != FONTS_MANIFEST_VERSION) {
|
||||
LOG_ERR("FONT", "Unsupported manifest version: %d", version);
|
||||
errorMessage_ = "Unsupported manifest version";
|
||||
return false;
|
||||
}
|
||||
|
||||
baseUrl_ = doc["baseUrl"] | "";
|
||||
families_.clear();
|
||||
|
||||
JsonArray familiesArr = doc["families"].as<JsonArray>();
|
||||
families_.reserve(familiesArr.size());
|
||||
|
||||
for (JsonObject fObj : familiesArr) {
|
||||
ManifestFamily family;
|
||||
family.name = fObj["name"] | "";
|
||||
family.description = fObj["description"] | "";
|
||||
|
||||
for (JsonVariant s : fObj["styles"].as<JsonArray>()) {
|
||||
family.styles.push_back(s.as<std::string>());
|
||||
}
|
||||
|
||||
family.totalSize = 0;
|
||||
for (JsonObject fileObj : fObj["files"].as<JsonArray>()) {
|
||||
ManifestFile file;
|
||||
file.name = fileObj["name"] | "";
|
||||
file.size = fileObj["size"] | 0;
|
||||
family.totalSize += file.size;
|
||||
family.files.push_back(std::move(file));
|
||||
}
|
||||
|
||||
family.installed = fontInstaller_.isFamilyInstalled(family.name.c_str());
|
||||
|
||||
// Detect updates by comparing manifest file sizes with files on disk.
|
||||
// Not a checksum, but a size mismatch reliably indicates a rebuild in practice.
|
||||
if (family.installed) {
|
||||
for (const auto& file : family.files) {
|
||||
char path[128];
|
||||
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), path, sizeof(path));
|
||||
FsFile f;
|
||||
if (Storage.openFileForRead("FONT", path, f)) {
|
||||
size_t actual = f.fileSize();
|
||||
f.close();
|
||||
if (actual != file.size) {
|
||||
family.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// File missing on disk but family dir exists — treat as update
|
||||
family.hasUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
families_.push_back(std::move(family));
|
||||
}
|
||||
|
||||
LOG_DBG("FONT", "Manifest loaded: %zu families", families_.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Download ---
|
||||
|
||||
void FontDownloadActivity::downloadAll() {
|
||||
for (size_t i = 0; i < families_.size(); i++) {
|
||||
if (families_[i].installed && !families_[i].hasUpdate) continue;
|
||||
downloadFamily(families_[i]);
|
||||
if (state_ == ERROR) return;
|
||||
}
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
}
|
||||
|
||||
size_t FontDownloadActivity::totalUninstalledSize() const {
|
||||
size_t total = 0;
|
||||
for (const auto& f : families_) {
|
||||
if (!f.installed || f.hasUpdate) total += f.totalSize;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
void FontDownloadActivity::downloadFamily(ManifestFamily& family) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = DOWNLOADING;
|
||||
downloadingFamilyIndex_ = static_cast<int>(&family - families_.data());
|
||||
currentFileIndex_ = 0;
|
||||
currentFileTotal_ = family.files.size();
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = 0;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
if (!fontInstaller_.ensureFamilyDir(family.name.c_str())) {
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Failed to create font directory";
|
||||
return;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < family.files.size(); i++) {
|
||||
const auto& file = family.files[i];
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
currentFileIndex_ = i;
|
||||
fileProgress_ = 0;
|
||||
fileTotal_ = file.size;
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
|
||||
char destPath[128];
|
||||
FontInstaller::buildFontPath(family.name.c_str(), file.name.c_str(), destPath, sizeof(destPath));
|
||||
|
||||
std::string url = baseUrl_ + file.name;
|
||||
|
||||
auto result = HttpDownloader::downloadToFile(url, destPath, [this](size_t downloaded, size_t total) {
|
||||
fileProgress_ = downloaded;
|
||||
fileTotal_ = total;
|
||||
requestUpdate(true);
|
||||
});
|
||||
|
||||
if (result != HttpDownloader::OK) {
|
||||
LOG_ERR("FONT", "Download failed: %s (%d)", file.name.c_str(), result);
|
||||
fontInstaller_.deleteFamily(family.name.c_str());
|
||||
family.installed = false;
|
||||
family.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Download failed: " + file.name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fontInstaller_.validateCpfontFile(destPath)) {
|
||||
LOG_ERR("FONT", "Invalid .cpfont: %s", destPath);
|
||||
fontInstaller_.deleteFamily(family.name.c_str());
|
||||
family.installed = false;
|
||||
family.hasUpdate = false;
|
||||
RenderLock lock(*this);
|
||||
state_ = ERROR;
|
||||
errorMessage_ = "Invalid font file: " + file.name;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fontInstaller_.refreshRegistry();
|
||||
family.installed = true;
|
||||
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = COMPLETE;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input handling ---
|
||||
|
||||
void FontDownloadActivity::loop() {
|
||||
if (state_ == FAMILY_LIST) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this] {
|
||||
if (selectedIndex_ < listItemCount() - 1) {
|
||||
selectedIndex_++;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
buttonNavigator_.onPreviousRelease([this] {
|
||||
if (selectedIndex_ > 0) {
|
||||
selectedIndex_--;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (!families_.empty()) {
|
||||
if (isDownloadAllSelected()) {
|
||||
downloadAll();
|
||||
} else {
|
||||
const auto& family = families_[familyIndexFromList(selectedIndex_)];
|
||||
if (!family.installed || family.hasUpdate) {
|
||||
downloadFamily(families_[familyIndexFromList(selectedIndex_)]);
|
||||
}
|
||||
}
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (state_ == COMPLETE) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
} else if (state_ == ERROR) {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (downloadingFamilyIndex_ >= 0 && downloadingFamilyIndex_ < static_cast<int>(families_.size())) {
|
||||
downloadFamily(families_[downloadingFamilyIndex_]);
|
||||
requestUpdateAndWait();
|
||||
return;
|
||||
} else {
|
||||
{
|
||||
RenderLock lock(*this);
|
||||
state_ = FAMILY_LIST;
|
||||
}
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
std::string FontDownloadActivity::formatSize(size_t bytes) {
|
||||
char buf[32];
|
||||
if (bytes >= 1024 * 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.1f MB", static_cast<double>(bytes) / (1024.0 * 1024.0));
|
||||
} else if (bytes >= 1024) {
|
||||
snprintf(buf, sizeof(buf), "%.0f KB", static_cast<double>(bytes) / 1024.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%zu B", bytes);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
void FontDownloadActivity::render(RenderLock&&) {
|
||||
const 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_FONT_DOWNLOAD));
|
||||
|
||||
const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const auto contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const auto centerY = (pageHeight - lineHeight) / 2;
|
||||
|
||||
if (state_ == LOADING_MANIFEST) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_LOADING_FONT_LIST));
|
||||
} else if (state_ == FAMILY_LIST) {
|
||||
if (families_.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_NO_FONTS_AVAILABLE));
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else {
|
||||
GUI.drawList(
|
||||
renderer,
|
||||
Rect{0, contentTop, pageWidth, pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing},
|
||||
listItemCount(), selectedIndex_,
|
||||
[this](int index) -> std::string {
|
||||
if (index == 0) {
|
||||
return std::string(tr(STR_DOWNLOAD_ALL)) + " (" + formatSize(totalUninstalledSize()) + ")";
|
||||
}
|
||||
return families_[familyIndexFromList(index)].name;
|
||||
},
|
||||
nullptr, nullptr,
|
||||
[this](int index) -> std::string {
|
||||
if (index == 0) return "";
|
||||
const auto& f = families_[familyIndexFromList(index)];
|
||||
if (f.hasUpdate) return tr(STR_UPDATE_AVAILABLE);
|
||||
if (f.installed) return tr(STR_INSTALLED);
|
||||
return f.description;
|
||||
},
|
||||
true,
|
||||
[this](int index) -> bool {
|
||||
if (index == 0) return false;
|
||||
const auto& f = families_[familyIndexFromList(index)];
|
||||
// Dim installed fonts, but not those with updates available
|
||||
return f.installed && !f.hasUpdate;
|
||||
});
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
} else if (state_ == DOWNLOADING) {
|
||||
const auto& family = families_[downloadingFamilyIndex_];
|
||||
|
||||
std::string statusText = std::string(tr(STR_DOWNLOADING)) + " " + family.name + " (" +
|
||||
std::to_string(currentFileIndex_ + 1) + "/" + std::to_string(currentFileTotal_) + ")";
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, statusText.c_str());
|
||||
|
||||
float progress = 0;
|
||||
if (fileTotal_ > 0) {
|
||||
progress = static_cast<float>(fileProgress_) / static_cast<float>(fileTotal_);
|
||||
}
|
||||
|
||||
int barY = centerY + metrics.verticalSpacing;
|
||||
GUI.drawProgressBar(
|
||||
renderer,
|
||||
Rect{metrics.contentSidePadding, barY, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight},
|
||||
static_cast<int>(progress * 100), 100);
|
||||
|
||||
int percentY = barY + metrics.progressBarHeight + metrics.verticalSpacing;
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, percentY,
|
||||
(std::to_string(static_cast<int>(progress * 100)) + "%").c_str());
|
||||
} else if (state_ == COMPLETE) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, tr(STR_FONT_INSTALLED), true, EpdFontFamily::BOLD);
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
} else if (state_ == ERROR) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - lineHeight, tr(STR_FONT_INSTALL_FAILED), true,
|
||||
EpdFontFamily::BOLD);
|
||||
if (!errorMessage_.empty()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + metrics.verticalSpacing, errorMessage_.c_str());
|
||||
}
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", "");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "FontInstaller.h"
|
||||
#include "SdCardFont.h"
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
// JSON schema version of the fonts.json manifest. The canonical version for
|
||||
// the build tooling lives in lib/EpdFont/scripts/cpfont_version.py. This
|
||||
// firmware-side copy must be bumped manually when the firmware is updated to
|
||||
// support a new manifest schema.
|
||||
#define FONTS_MANIFEST_VERSION 1
|
||||
|
||||
#ifndef FONT_MANIFEST_URL
|
||||
// Manifest + .cpfont assets are published by .github/workflows/release-fonts.yml
|
||||
// to the crosspoint-fonts repo under the "sd-fonts-m<META>-b<BIN>" tag. The tag
|
||||
// pattern must stay in sync with the workflow; it derives its version numbers
|
||||
// from lib/EpdFont/scripts/cpfont_version.py.
|
||||
#define FONT_MANIFEST_URL_STRINGIFY_INNER(x) #x
|
||||
#define FONT_MANIFEST_URL_STRINGIFY(x) FONT_MANIFEST_URL_STRINGIFY_INNER(x)
|
||||
#define FONT_MANIFEST_URL \
|
||||
"https://github.com/crosspoint-reader/crosspoint-fonts/releases/download/sd-fonts-m" FONT_MANIFEST_URL_STRINGIFY( \
|
||||
FONTS_MANIFEST_VERSION) "-b" FONT_MANIFEST_URL_STRINGIFY(CPFONT_VERSION) "/fonts.json"
|
||||
#endif
|
||||
|
||||
class FontDownloadActivity : public Activity {
|
||||
public:
|
||||
explicit FontDownloadActivity(GfxRenderer& renderer, MappedInputManager& mappedInput);
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
bool preventAutoSleep() override { return state_ == LOADING_MANIFEST || state_ == DOWNLOADING; }
|
||||
bool skipLoopDelay() override { return true; }
|
||||
|
||||
private:
|
||||
enum State {
|
||||
WIFI_SELECTION,
|
||||
LOADING_MANIFEST,
|
||||
FAMILY_LIST,
|
||||
DOWNLOADING,
|
||||
COMPLETE,
|
||||
ERROR,
|
||||
};
|
||||
|
||||
struct ManifestFile {
|
||||
std::string name;
|
||||
size_t size = 0;
|
||||
};
|
||||
|
||||
struct ManifestFamily {
|
||||
std::string name;
|
||||
std::string description;
|
||||
std::vector<std::string> styles;
|
||||
std::vector<ManifestFile> files;
|
||||
size_t totalSize = 0;
|
||||
bool installed = false;
|
||||
bool hasUpdate = false;
|
||||
};
|
||||
|
||||
State state_ = WIFI_SELECTION;
|
||||
FontInstaller fontInstaller_;
|
||||
ButtonNavigator buttonNavigator_;
|
||||
|
||||
// Manifest data
|
||||
std::string baseUrl_;
|
||||
std::vector<ManifestFamily> families_;
|
||||
int selectedIndex_ = 0;
|
||||
|
||||
// Download progress
|
||||
size_t currentFileIndex_ = 0;
|
||||
size_t currentFileTotal_ = 0;
|
||||
size_t fileProgress_ = 0;
|
||||
size_t fileTotal_ = 0;
|
||||
int downloadingFamilyIndex_ = 0;
|
||||
std::string errorMessage_;
|
||||
|
||||
void onWifiSelectionComplete(bool success);
|
||||
bool fetchAndParseManifest();
|
||||
void downloadFamily(ManifestFamily& family);
|
||||
void downloadAll();
|
||||
bool isDownloadAllSelected() const { return selectedIndex_ == 0 && !families_.empty(); }
|
||||
int familyIndexFromList(int listIndex) const { return listIndex - 1; }
|
||||
int listItemCount() const { return families_.empty() ? 0 : static_cast<int>(families_.size()) + 1; }
|
||||
size_t totalUninstalledSize() const;
|
||||
static std::string formatSize(size_t bytes);
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
#include "FontSelectionActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const SdCardFontRegistry* registry)
|
||||
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
|
||||
|
||||
void FontSelectionActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Build combined font list: built-in + SD card fonts
|
||||
fonts_.clear();
|
||||
fonts_.reserve(CrossPointSettings::BUILTIN_FONT_COUNT + (registry_ ? registry_->getFamilyCount() : 0));
|
||||
|
||||
fonts_.push_back({I18N.get(StrId::STR_NOTO_SERIF), true, 0});
|
||||
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, 1});
|
||||
fonts_.push_back({I18N.get(StrId::STR_OPEN_DYSLEXIC), true, 2});
|
||||
|
||||
if (registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
fonts_.push_back({families[i].name, false, static_cast<uint8_t>(CrossPointSettings::BUILTIN_FONT_COUNT + i)});
|
||||
}
|
||||
}
|
||||
|
||||
// Find current selection
|
||||
selectedIndex_ = 0;
|
||||
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||
selectedIndex_ = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
selectedIndex_ = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||
}
|
||||
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void FontSelectionActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void FontSelectionActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
handleSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
buttonNavigator_.onNextRelease([this] {
|
||||
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
|
||||
buttonNavigator_.onPreviousRelease([this] {
|
||||
selectedIndex_ = ButtonNavigator::previousIndex(selectedIndex_, static_cast<int>(fonts_.size()));
|
||||
requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
void FontSelectionActivity::handleSelection() {
|
||||
const auto& font = fonts_[selectedIndex_];
|
||||
if (font.settingIndex < CrossPointSettings::BUILTIN_FONT_COUNT) {
|
||||
SETTINGS.fontFamily = font.settingIndex;
|
||||
SETTINGS.sdFontFamilyName[0] = '\0';
|
||||
} else if (registry_) {
|
||||
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
|
||||
const auto& families = registry_->getFamilies();
|
||||
if (sdIdx < static_cast<int>(families.size())) {
|
||||
strncpy(SETTINGS.sdFontFamilyName, families[sdIdx].name.c_str(), sizeof(SETTINGS.sdFontFamilyName) - 1);
|
||||
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
finish();
|
||||
}
|
||||
|
||||
void FontSelectionActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_FONT_FAMILY));
|
||||
|
||||
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
|
||||
|
||||
// Determine which font index is currently active (to mark as "Selected")
|
||||
int currentFontIndex = 0;
|
||||
if (SETTINGS.sdFontFamilyName[0] != '\0' && registry_) {
|
||||
const auto& families = registry_->getFamilies();
|
||||
for (int i = 0; i < static_cast<int>(families.size()); i++) {
|
||||
if (families[i].name == SETTINGS.sdFontFamilyName) {
|
||||
currentFontIndex = CrossPointSettings::BUILTIN_FONT_COUNT + i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentFontIndex = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0;
|
||||
}
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
|
||||
[this](int index) { return fonts_[index].name; }, nullptr, nullptr,
|
||||
[this, currentFontIndex](int index) -> std::string { return index == currentFontIndex ? tr(STR_SELECTED) : ""; },
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <SdCardFontRegistry.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
class FontSelectionActivity final : public Activity {
|
||||
public:
|
||||
explicit FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const SdCardFontRegistry* registry);
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(RenderLock&&) override;
|
||||
|
||||
private:
|
||||
void handleSelection();
|
||||
|
||||
struct FontEntry {
|
||||
std::string name;
|
||||
bool isBuiltin;
|
||||
uint8_t settingIndex; // index used by valueSetter
|
||||
};
|
||||
|
||||
const SdCardFontRegistry* registry_;
|
||||
ButtonNavigator buttonNavigator_;
|
||||
std::vector<FontEntry> fonts_;
|
||||
int selectedIndex_ = 0;
|
||||
};
|
||||
@@ -6,11 +6,14 @@
|
||||
#include "ButtonRemapActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "FontDownloadActivity.h"
|
||||
#include "FontSelectionActivity.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerListActivity.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SdCardFontGlobals.h"
|
||||
#include "SdFirmwareUpdateActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "StatusBarSettingsActivity.h"
|
||||
@@ -21,16 +24,17 @@
|
||||
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
|
||||
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Build per-category vectors from the shared settings list
|
||||
void SettingsActivity::rebuildSettingsLists() {
|
||||
displaySettings.clear();
|
||||
readerSettings.clear();
|
||||
controlsSettings.clear();
|
||||
systemSettings.clear();
|
||||
|
||||
for (const auto& setting : getSettingsList()) {
|
||||
// Pick up any fonts uploaded/deleted over the web server since the last
|
||||
// reader activity ran — otherwise the font-family picker shows stale list.
|
||||
sdFontSystem.refreshIfDirty();
|
||||
|
||||
for (auto& setting : getSettingsList(&sdFontSystem.registry())) {
|
||||
if (setting.category == StrId::STR_NONE_OPT) continue;
|
||||
if (setting.category == StrId::STR_CAT_DISPLAY) {
|
||||
displaySettings.push_back(setting);
|
||||
@@ -41,7 +45,6 @@ void SettingsActivity::onEnter() {
|
||||
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
|
||||
systemSettings.push_back(setting);
|
||||
}
|
||||
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
|
||||
}
|
||||
|
||||
// Append device-only ACTION items
|
||||
@@ -51,18 +54,41 @@ void SettingsActivity::onEnter() {
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_SD_FIRMWARE_UPDATE, SettingAction::SdFirmwareUpdate));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language));
|
||||
// Insert "Download Fonts" right after the font family setting so users discover it naturally
|
||||
readerSettings.insert(readerSettings.begin() + 1,
|
||||
SettingInfo::Action(StrId::STR_DOWNLOAD_FONTS, SettingAction::DownloadFonts));
|
||||
readerSettings.push_back(SettingInfo::Action(StrId::STR_CUSTOMISE_STATUS_BAR, SettingAction::CustomiseStatusBar));
|
||||
|
||||
// Update currentSettings pointer and count for the active category
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0:
|
||||
currentSettings = &displaySettings;
|
||||
break;
|
||||
case 1:
|
||||
currentSettings = &readerSettings;
|
||||
break;
|
||||
case 2:
|
||||
currentSettings = &controlsSettings;
|
||||
break;
|
||||
case 3:
|
||||
currentSettings = &systemSettings;
|
||||
break;
|
||||
}
|
||||
settingsCount = static_cast<int>(currentSettings->size());
|
||||
}
|
||||
|
||||
void SettingsActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Reset selection to first category
|
||||
selectedCategoryIndex = 0;
|
||||
selectedSettingIndex = 0;
|
||||
|
||||
// Initialize with first category (Display)
|
||||
currentSettings = &displaySettings;
|
||||
settingsCount = static_cast<int>(displaySettings.size());
|
||||
rebuildSettingsLists();
|
||||
|
||||
// Trigger first update
|
||||
requestUpdate();
|
||||
@@ -159,6 +185,21 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
|
||||
if (setting.nameId == StrId::STR_FONT_FAMILY) {
|
||||
// Launch font selection submenu instead of cycling
|
||||
startActivityForResult(std::make_unique<FontSelectionActivity>(renderer, mappedInput, &sdFontSystem.registry()),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
return;
|
||||
}
|
||||
const uint8_t totalValues = setting.enumStringValues.empty()
|
||||
? static_cast<uint8_t>(setting.enumValues.size())
|
||||
: static_cast<uint8_t>(setting.enumStringValues.size());
|
||||
const uint8_t cur = setting.valueGetter();
|
||||
setting.valueSetter((cur + 1) % totalValues);
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
||||
@@ -194,6 +235,13 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::SdFirmwareUpdate:
|
||||
startActivityForResult(std::make_unique<SdFirmwareUpdateActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
case SettingAction::DownloadFonts:
|
||||
startActivityForResult(std::make_unique<FontDownloadActivity>(renderer, mappedInput),
|
||||
[this](const ActivityResult&) {
|
||||
SETTINGS.saveToFile();
|
||||
rebuildSettingsLists();
|
||||
});
|
||||
break;
|
||||
case SettingAction::Language:
|
||||
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
@@ -245,6 +293,13 @@ void SettingsActivity::render(RenderLock&&) {
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(setting.valuePtr);
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
|
||||
const uint8_t value = setting.valueGetter();
|
||||
if (!setting.enumStringValues.empty() && value < setting.enumStringValues.size()) {
|
||||
valueText = setting.enumStringValues[value];
|
||||
} else if (value < setting.enumValues.size()) {
|
||||
valueText = I18N.get(setting.enumValues[value]);
|
||||
}
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ enum class SettingAction {
|
||||
CheckForUpdates,
|
||||
SdFirmwareUpdate,
|
||||
Language,
|
||||
DownloadFonts,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
@@ -29,6 +30,7 @@ struct SettingInfo {
|
||||
SettingType type;
|
||||
uint8_t CrossPointSettings::* valuePtr = nullptr;
|
||||
std::vector<StrId> enumValues;
|
||||
std::vector<std::string> enumStringValues; // runtime alternative to StrId enumValues (for SD card fonts etc.)
|
||||
SettingAction action = SettingAction::None;
|
||||
|
||||
struct ValueRange {
|
||||
@@ -159,6 +161,7 @@ class SettingsActivity final : public Activity {
|
||||
|
||||
void enterCategory(int categoryIndex);
|
||||
void toggleCurrentSetting();
|
||||
void rebuildSettingsLists();
|
||||
|
||||
public:
|
||||
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
|
||||
Reference in New Issue
Block a user