feat: add live font preview pane to font selection screen (#2349)

## Summary
* Adds a live font preview pane to the font selection screen so users
can see how a font looks before committing to it.
* Changes
* A preview pane occupying the top 30% of the font selection screen,
rendering sample pangram text in the previewed font
* A two-step confirm flow: first press (enter button) previews the font,
second press selects it
* Back restores the original font settings, so browsing has no side
effects
* Layout dimensions cached in `onEnter()` to avoid redundant
recalculation between `loop()` and `render()`

## Additional Context
* Preview sample text is hardcoded English; didn't want to use AI for
translation as I would not be able to verify the output in most
languages...
* The preview pane reduces visible list height; this is compensated by
passing the reserved height into `getNumberOfItemsPerPage`

---
### AI Usage
Did you use AI tools to help write this code? **PARTIALLY**
AI use for assisting in coding and in writing the PR description.
This commit is contained in:
Paul Delestrac
2026-06-18 18:16:28 -04:00
committed by GitHub
parent 5e990b3991
commit b1131795d8
31 changed files with 176 additions and 41 deletions
+126 -39
View File
@@ -1,13 +1,36 @@
#include "FontSelectionActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <I18n.h>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "SdCardFontSystem.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr const char* ELLIPSIS_UTF8 = "\xe2\x80\xa6";
int findCurrentFontIndex(const SdCardFontRegistry* registry, const char* sdFontFamilyName, uint8_t fontFamily) {
if (sdFontFamilyName[0] != '\0' && registry) {
const auto& families = registry->getFamilies();
for (int i = 0; i < static_cast<int>(families.size()); i++) {
if (families[i].name == sdFontFamilyName) {
return CrossPointSettings::BUILTIN_FONT_COUNT + i;
}
}
}
return fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? fontFamily : 0;
}
} // namespace
FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const SdCardFontRegistry* registry)
: Activity("FontSelect", renderer, mappedInput), registry_(registry) {}
@@ -15,12 +38,22 @@ FontSelectionActivity::FontSelectionActivity(GfxRenderer& renderer, MappedInputM
void FontSelectionActivity::onEnter() {
Activity::onEnter();
// Build combined font list: built-in + SD card fonts
// Get metrics and calculate layout dimensions
metrics_ = UITheme::getInstance().getMetrics();
afterHeader = metrics_.topPadding + metrics_.headerHeight + metrics_.verticalSpacing;
bottomReserved = metrics_.buttonHintsHeight + metrics_.verticalSpacing;
usableHeight = renderer.getScreenHeight() - afterHeader - bottomReserved;
previewHeight = usableHeight * metrics_.previewHeightPercent / 100;
originalFontFamily_ = SETTINGS.fontFamily;
strncpy(originalSdFontFamilyName_, SETTINGS.sdFontFamilyName, sizeof(originalSdFontFamilyName_) - 1);
originalSdFontFamilyName_[sizeof(originalSdFontFamilyName_) - 1] = '\0';
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_NOTO_SERIF), true, static_cast<uint8_t>(CrossPointSettings::NOTOSERIF)});
fonts_.push_back({I18N.get(StrId::STR_NOTO_SANS), true, static_cast<uint8_t>(CrossPointSettings::NOTOSANS)});
if (registry_) {
const auto& families = registry_->getFamilies();
@@ -29,19 +62,8 @@ void FontSelectionActivity::onEnter() {
}
}
// 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;
}
selectedIndex_ = findCurrentFontIndex(registry_, SETTINGS.sdFontFamilyName, SETTINGS.fontFamily);
previewFontIndex_ = selectedIndex_;
requestUpdate();
}
@@ -50,17 +72,40 @@ void FontSelectionActivity::onExit() { Activity::onExit(); }
void FontSelectionActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.fontFamily = originalFontFamily_;
strncpy(SETTINGS.sdFontFamilyName, originalSdFontFamilyName_, sizeof(SETTINGS.sdFontFamilyName) - 1);
SETTINGS.sdFontFamilyName[sizeof(SETTINGS.sdFontFamilyName) - 1] = '\0';
sdFontSystem.ensureLoaded(renderer);
finish();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleSelection();
if (selectedIndex_ == previewFontIndex_) {
handleSelection();
} else {
previewFontIndex_ = selectedIndex_;
const auto& font = fonts_[selectedIndex_];
if (font.isBuiltin) {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
const 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';
sdFontSystem.ensureLoaded(renderer);
}
}
requestUpdate();
}
return;
}
const int listSize = static_cast<int>(fonts_.size());
const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false);
const int pageItems =
UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, previewHeight + metrics_.verticalSpacing);
buttonNavigator_.onNextRelease([this, listSize] {
selectedIndex_ = ButtonNavigator::nextIndex(selectedIndex_, listSize);
@@ -89,7 +134,7 @@ void FontSelectionActivity::handleSelection() {
SETTINGS.fontFamily = font.settingIndex;
SETTINGS.sdFontFamilyName[0] = '\0';
} else if (registry_) {
int sdIdx = font.settingIndex - CrossPointSettings::BUILTIN_FONT_COUNT;
const 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);
@@ -99,39 +144,81 @@ void FontSelectionActivity::handleSelection() {
finish();
}
void FontSelectionActivity::renderPreviewPane(int top, int height, int fontId, const char* fontName) const {
const int left = metrics_.previewPadding;
const int width = renderer.getScreenWidth() - (metrics_.previewPadding * 2);
if (width <= 0 || height <= 0) return;
const int labelFontId = UI_10_FONT_ID;
const int labelH = renderer.getTextHeight(labelFontId);
const int labelGap = 4;
const int labelReserved = labelH + labelGap + metrics_.previewPadding;
char labelBuf[128];
snprintf(labelBuf, sizeof(labelBuf), "%s \"%s\"", tr(STR_PREVIEW), fontName ? fontName : "");
const int labelY = top + height - metrics_.previewPadding - labelH;
renderer.drawText(labelFontId, left, labelY, labelBuf);
if (fontId == 0) return;
const int lineH = renderer.getTextHeight(fontId);
if (lineH <= 0) return;
const int innerHeight = height - metrics_.previewPadding - labelReserved;
const int maxLines = std::max(1, innerHeight / (lineH + 2));
const char* previewText = I18N.get(StrId::STR_FONT_PREVIEW_TEXT);
if (auto* fcm = renderer.getFontCacheManager()) {
char prewarmBuf[256];
snprintf(prewarmBuf, sizeof(prewarmBuf), "%s %s", previewText, ELLIPSIS_UTF8);
fcm->prewarmCache(fontId, prewarmBuf, 0x01);
}
const auto lines = renderer.wrappedText(fontId, previewText, width, maxLines);
int y = top + metrics_.previewPadding;
const int textBottomLimit = top + height - labelReserved;
for (const auto& line : lines) {
if (y + lineH > textBottomLimit) break;
renderer.drawText(fontId, left, y, line.c_str());
y += lineH + 2;
}
}
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));
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;
const int previewTop = afterHeader;
const int listTop = previewTop + previewHeight + metrics_.verticalSpacing;
const int listHeight = usableHeight - previewHeight - 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;
}
const int previewFontId = SETTINGS.getReaderFontId();
const char* previewFontName = (previewFontIndex_ >= 0 && previewFontIndex_ < static_cast<int>(fonts_.size()))
? fonts_[previewFontIndex_].name.c_str()
: nullptr;
renderPreviewPane(previewTop, previewHeight, previewFontId, previewFontName);
renderer.drawLine(0, listTop - metrics_.verticalSpacing / 2, pageWidth, listTop - metrics_.verticalSpacing / 2);
const int currentFontIndex = findCurrentFontIndex(registry_, originalSdFontFamilyName_, originalFontFamily_);
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast<int>(fonts_.size()), selectedIndex_,
renderer, Rect{0, listTop, pageWidth, listHeight}, 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) : ""; },
[this, currentFontIndex](int index) -> std::string {
if (index == previewFontIndex_ && index != currentFontIndex) return tr(STR_PREVIEW);
if (index == currentFontIndex) return tr(STR_SELECTED);
return "";
},
true);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
const bool onPreviewed = selectedIndex_ == previewFontIndex_;
const char* confirmLabel = onPreviewed ? tr(STR_SELECT) : tr(STR_PREVIEW);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
@@ -2,10 +2,12 @@
#include <SdCardFontRegistry.h>
#include <cstdint>
#include <string>
#include <vector>
#include "activities/Activity.h"
#include "components/themes/BaseTheme.h"
#include "util/ButtonNavigator.h"
class FontSelectionActivity final : public Activity {
@@ -20,15 +22,26 @@ class FontSelectionActivity final : public Activity {
private:
void handleSelection();
int getFontIdForPreview(int index) const;
void renderPreviewPane(int top, int height, int fontId, const char* fontName) const;
struct FontEntry {
std::string name;
bool isBuiltin;
uint8_t settingIndex; // index used by valueSetter
uint8_t settingIndex;
};
const SdCardFontRegistry* registry_;
ButtonNavigator buttonNavigator_;
std::vector<FontEntry> fonts_;
int selectedIndex_ = 0;
int previewFontIndex_ = 0;
uint8_t originalFontFamily_ = 0;
char originalSdFontFamilyName_[32] = {};
ThemeMetrics metrics_ = {};
int afterHeader = 0;
int bottomReserved = 0;
int usableHeight = 0;
int previewHeight = 0;
};
+5
View File
@@ -32,6 +32,9 @@ struct ThemeMetrics {
int headerHeight;
int verticalSpacing;
int previewPadding;
int previewHeightPercent;
int contentSidePadding;
int listRowHeight;
int listWithSubtitleRowHeight;
@@ -111,6 +114,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.batteryBarHeight = 20,
.headerHeight = 45,
.verticalSpacing = 10,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 30,
.listWithSubtitleRowHeight = 50,
+2
View File
@@ -12,6 +12,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.batteryBarHeight = 40,
.headerHeight = 84,
.verticalSpacing = 16,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
@@ -11,6 +11,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.batteryBarHeight = 20,
.headerHeight = 45,
.verticalSpacing = 10,
.previewPadding = 12,
.previewHeightPercent = 30,
.contentSidePadding = 20,
.listRowHeight = 42,
.listWithSubtitleRowHeight = 69,