Add icon generation script and list scroll helper

Introduce gen_icons.sh to regenerate UI icons from a manifest file using the FreeInk SDK's Lucide icon set. Add icons.manifest mapping UI icons to Lucide names. Refactor vertical swipe list scrolling into a reusable wasListScroll() helper method used by both reader chapter selection and settings activities. Add static assertion to ensure ThemeMetrics scaling stays in sync with struct changes.
This commit is contained in:
Justin Mitchell
2026-06-16 00:15:18 -04:00
parent 1c63847631
commit 4a17d21d80
7 changed files with 86 additions and 37 deletions
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Regenerate src/components/icons/generated_icons.h from icons.manifest, using the
# FreeInk SDK icon generator and its vendored Lucide submodule. generated_icons.h is
# a committed build product of these inputs — re-run this after editing the manifest.
#
# Requires: rsvg-convert (librsvg) + Pillow, and the Lucide submodule fetched
# (git submodule update --init in the SDK).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SDK="$ROOT/freeink-sdk"
SVGDIR="$SDK/libs/assets/Icons/lucide/icons"
if [ ! -d "$SVGDIR" ]; then
echo "Lucide SVGs not found at $SVGDIR" >&2
echo "Fetch the submodule: git -C \"$SDK\" submodule update --init libs/assets/Icons/lucide" >&2
exit 1
fi
python3 "$SDK/libs/assets/Icons/tools/gen_icons.py" \
--manifest "$ROOT/src/components/icons/icons.manifest" \
--svgdir "$SVGDIR" \
--sizes 24,32,40,48 \
--out "$ROOT/src/components/icons/generated_icons.h"
echo "Regenerated src/components/icons/generated_icons.h"
@@ -56,6 +56,12 @@ void XtcReaderChapterSelectionActivity::loop() {
const int pageItems = getPageItems();
const int totalItems = static_cast<int>(xtc->getChapters().size());
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
if (mappedInput.wasListScroll(selectorIndex, totalItems, pageItems)) {
requestUpdate();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
const auto& chapters = xtc->getChapters();
if (!chapters.empty() && selectorIndex >= 0 && selectorIndex < static_cast<int>(chapters.size())) {
+3 -7
View File
@@ -131,15 +131,11 @@ void SettingsActivity::loop() {
hasChangedCategory = true;
requestUpdate();
} else if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) {
// Vertical swipe scrolls the settings list a page at a time (touch navigation
// without the side buttons). Swipe up moves down the list, and vice versa.
// Vertical swipe page-scrolls the list (touch nav without the side buttons).
// count is settingsCount + 1 because row 0 is the category tab bar.
const int page = std::max(
1, UITheme::getNumberOfItemsPerPage(renderer, true, true, BaseTheme::showButtonHints(), false));
if (swipe == MappedInputManager::SwipeDir::Up) {
selectedSettingIndex = std::min(selectedSettingIndex + page, settingsCount);
} else {
selectedSettingIndex = std::max(selectedSettingIndex - page, 0);
}
mappedInput.wasListScroll(selectedSettingIndex, settingsCount + 1, page);
requestUpdate();
}
+7
View File
@@ -25,6 +25,13 @@ int sp(int v, float s) { return static_cast<int>(std::lround(v * s)); }
} // namespace
ThemeMetrics scaleThemeMetrics(const ThemeMetrics& b, float s) {
// Every pixel field is scaled explicitly below. Counts, percents, ratios, bools,
// and the intentionally-unscaled chrome (homeCover*, statusBar*, progressBar*) are
// NOT touched. This guard fails when a ThemeMetrics field is added or removed —
// classify the new field (scale it, or leave it and note it here) and bump the
// expected size, so scaling can never silently miss a field.
static_assert(sizeof(ThemeMetrics) == THEME_METRICS_SIZEOF,
"ThemeMetrics changed: review scaleThemeMetrics() and update THEME_METRICS_SIZEOF");
ThemeMetrics m = b;
if (s == 1.0f) return m;
m.batteryWidth = sp(b.batteryWidth, s);
+15
View File
@@ -0,0 +1,15 @@
# CrossPoint UI icon set: each UIIcon -> a Lucide icon name.
# Edit this, then run scripts/gen_icons.sh to regenerate generated_icons.h.
# Browse names in freeink-sdk/libs/assets/Icons/lucide/icons/*.svg.
folder = folder
text = file-text
image = image
book = book-open
file = file
recent = clock
settings = settings
transfer = arrow-down-up
library = library-big
wifi = wifi
hotspot = router
bookmark = bookmark
+5
View File
@@ -104,6 +104,11 @@ struct ThemeMetrics {
int textFieldLineEndOffset;
};
// Expected sizeof(ThemeMetrics), in bytes. scaleThemeMetrics() static_asserts
// against this so a new/removed metric field can't slip through unclassified;
// update it after adding a field (and decide there whether the field scales).
inline constexpr unsigned THEME_METRICS_SIZEOF = 216;
enum UIIcon { None = 0, Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Bookmark };
enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled };
+24 -30
View File
@@ -34,9 +34,29 @@ constexpr int listIconSize = 24;
constexpr int mainMenuColumns = 2;
int coverWidth = 0;
// Pick the generated Lucide icon variant nearest to targetPx for a UIIcon. The
// generator emits 24/32/40/48px with each icon's optical center baked in, so a
// scaled UI gets a crisp larger asset (not an upscaled one) and exact alignment.
// The generated icon sizes (px). The generator (gen_icons.py) emits these.
constexpr int kIconSizes[] = {24, 32, 40, 48};
// Scale a base icon size by the board uiScale.
int scaledIcon(int base) { return static_cast<int>(base * UITheme::uiScale() + 0.5f); }
// Index of the generated size nearest targetPx, and that size in px.
int iconVariantIndex(int targetPx) {
int best = 0, bestDist = 1 << 30;
for (int i = 0; i < 4; ++i) {
const int d = kIconSizes[i] > targetPx ? kIconSizes[i] - targetPx : targetPx - kIconSizes[i];
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
int nearestIconSize(int targetPx) { return kIconSizes[iconVariantIndex(targetPx)]; }
// Generated Lucide icon variant nearest targetPx for a UIIcon. Each icon ships at
// every size with its optical center baked in, so a scaled UI gets a crisp larger
// asset (not an upscale) with exact alignment.
const freeink::Icon* pickIcon(UIIcon icon, int targetPx) {
const freeink::Icon* const* v = nullptr;
#define VARIANTS(n) \
@@ -61,33 +81,7 @@ const freeink::Icon* pickIcon(UIIcon icon, int targetPx) {
default: return nullptr;
}
#undef VARIANTS
static constexpr int kSizes[] = {24, 32, 40, 48};
int best = 0, bestDist = 1 << 30;
for (int i = 0; i < 4; ++i) {
const int d = kSizes[i] > targetPx ? kSizes[i] - targetPx : targetPx - kSizes[i];
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return v[best];
}
// Scale a base icon size by the board uiScale.
int scaledIcon(int base) { return static_cast<int>(base * UITheme::uiScale() + 0.5f); }
// Nearest generated icon size (24/32/40/48) to targetPx.
int nearestIconSize(int targetPx) {
static constexpr int kSizes[] = {24, 32, 40, 48};
int best = kSizes[0], bestDist = 1 << 30;
for (int s : kSizes) {
const int d = s > targetPx ? s - targetPx : targetPx - s;
if (d < bestDist) {
bestDist = d;
best = s;
}
}
return best;
return v[iconVariantIndex(targetPx)];
}
} // namespace