Add UI scaling support for touch vs button devices

Introduces uiScale() method to apply per-board UI scaling based on device type (1.0 for button devices, >1 for touch devices to make UI elements finger-sized). Adds scaledMetrics member to store scaled theme metrics that are returned by getMetrics().
This commit is contained in:
Justin Mitchell
2026-06-15 22:42:33 -04:00
parent cef0d267b7
commit e103cdfd11
17 changed files with 506 additions and 182 deletions
+52 -10
View File
@@ -100,6 +100,23 @@ void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
}
}
void GfxRenderer::setUiFontRemap(const int* from, const int* to, int count) {
if (count < 0) count = 0;
if (count > MAX_UI_FONT_REMAP) count = MAX_UI_FONT_REMAP;
for (int i = 0; i < count; ++i) {
uiFontFrom_[i] = from[i];
uiFontTo_[i] = to[i];
}
uiFontRemapCount_ = count;
}
int GfxRenderer::remapUiFont(const int fontId) const {
for (int i = 0; i < uiFontRemapCount_; ++i) {
if (uiFontFrom_[i] == fontId) return uiFontTo_[i];
}
return fontId;
}
// Translate logical (x,y) coordinates to physical panel coordinates based on current orientation
// This should always be inlined for better performance
static inline void rotateCoordinates(const GfxRenderer::Orientation orientation, const int x, const int y, int* phyX,
@@ -365,7 +382,7 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF
return 0;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -407,7 +424,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
@@ -1419,7 +1436,7 @@ int GfxRenderer::getSpaceWidth(const int fontId, const EpdFontFamily::Style styl
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1440,7 +1457,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
return fp4::toPixel(sdIt->second->getAdvance(' ', resolvedStyle));
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) return 0;
const auto& font = fontIt->second;
const EpdGlyph* spaceGlyph = font.getGlyph(' ', style);
@@ -1454,7 +1471,7 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const
int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp,
const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) return 0;
const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point
return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel
@@ -1476,7 +1493,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
return fp4::toPixel(widthFP);
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1512,7 +1529,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami
}
int GfxRenderer::getFontAscenderSize(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1522,7 +1539,7 @@ int GfxRenderer::getFontAscenderSize(const int fontId) const {
}
int GfxRenderer::getLineHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1532,7 +1549,7 @@ int GfxRenderer::getLineHeight(const int fontId) const {
}
int GfxRenderer::getTextHeight(const int fontId) const {
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return 0;
@@ -1540,6 +1557,31 @@ int GfxRenderer::getTextHeight(const int fontId) const {
return fontIt->second.getData(EpdFontFamily::REGULAR)->ascender;
}
// Height of a reference glyph above the baseline (glyph->top). 'H' gives the real
// cap height, 'x' the x-height — the actual visible extents of the font, for
// vertical centering that follows the font instead of a guessed ascender fraction.
int GfxRenderer::glyphTop(const int fontId, const uint32_t cp) const {
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) return 0;
const EpdGlyph* g = fontIt->second.getGlyph(cp, EpdFontFamily::REGULAR);
return g ? g->top : 0;
}
int GfxRenderer::getFontCapHeight(const int fontId) const { return glyphTop(fontId, 'H'); }
int GfxRenderer::getFontXHeight(const int fontId) const { return glyphTop(fontId, 'x'); }
int GfxRenderer::getTextVisualCenterOffset(const int fontId) const {
// Distance from drawText's y (text top) down to the text's optical middle. The
// baseline is at y + ascender; the lowercase mass is centered at half the
// x-height above it, so the optical center is ascender - xHeight/2 below the top.
// x-height-based (UI text is lowercase-heavy); falls back to ascender*0.7 if the
// 'x' glyph is missing. Scales with the font, so no per-size tweaking.
const int ascender = getFontAscenderSize(fontId);
const int xHeight = getFontXHeight(fontId);
if (xHeight <= 0) return (ascender * 7) / 10;
return ascender - xHeight / 2;
}
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
const EpdFontFamily::Style style) const {
// Cannot draw a NULL / empty string
@@ -1547,7 +1589,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
return;
}
const auto fontIt = fontMap.find(fontId);
const auto fontIt = fontMap.find(remapUiFont(fontId));
if (fontIt == fontMap.end()) {
LOG_ERR("GFX", "Font %d not found", fontId);
return;
+20
View File
@@ -51,6 +51,13 @@ class GfxRenderer {
uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE;
std::vector<uint8_t*> bwBufferChunks;
std::map<int, EpdFontFamily> fontMap;
// UI chrome font remap table (see setUiFontRemap). Empty = identity.
static constexpr int MAX_UI_FONT_REMAP = 8;
int uiFontFrom_[MAX_UI_FONT_REMAP] = {0};
int uiFontTo_[MAX_UI_FONT_REMAP] = {0};
int uiFontRemapCount_ = 0;
int remapUiFont(int fontId) const;
int glyphTop(int fontId, uint32_t cp) const;
// Mutable because ensureSdCardFontReady() is const (called from layout code
// that holds a const GfxRenderer&) but triggers SD card reads and heap
// allocation inside the SdCardFont objects. Same pragmatic compromise as
@@ -95,6 +102,11 @@ class GfxRenderer {
// Setup
void begin(); // must be called right after display.begin()
void insertFont(int fontId, EpdFontFamily font);
// UI chrome font scaling: firmware supplies a small remap table (from the board
// uiScale) that substitutes a larger font for each scaled UI font id at lookup
// time, so layout and drawing stay consistent with no call-site changes. Reader
// body fonts aren't in the table, so book text is unaffected. count <= 8.
void setUiFontRemap(const int* from, const int* to, int count);
// Clears both the flash-font map and any SD-font registration for fontId.
// Coupled to avoid dangling SdCardFont* in sdCardFonts_ when callers free
// the underlying SdCardFont and forget the SD-side unregister.
@@ -207,6 +219,14 @@ class GfxRenderer {
int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style) const;
int getFontAscenderSize(int fontId) const;
int getLineHeight(int fontId) const;
// Real visible font metrics (from the 'H' / 'x' glyph geometry) for vertical
// alignment that follows the font, not a guessed ascender fraction.
int getFontCapHeight(int fontId) const;
int getFontXHeight(int fontId) const;
// Offset from drawText's y (text top) to the text's optical vertical center.
// Center any element (icon, etc.) on a line of text via:
// centerY = textTop + getTextVisualCenterOffset(fontId)
int getTextVisualCenterOffset(int fontId) const;
std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
/// Word-wrap \p text into at most \p maxLines lines, each no wider than
+6
View File
@@ -249,8 +249,14 @@ bool HalGPIO::wasTouchDown(float& nx, float& ny) const { return inputMgr.wasTouc
unsigned long HalGPIO::lastTouchHeldMs() const { return inputMgr.lastTouchHeldMs(); }
bool HalGPIO::wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const {
return inputMgr.wasSwipe(nxStart, nyStart, nxEnd, nyEnd);
}
bool HalGPIO::hasTouch() const { return inputMgr.hasTouch(); }
bool HalGPIO::wasTouchActivity() const { return inputMgr.wasTouchActivity(); }
bool HalGPIO::isXteinkDevice() const {
const auto board = BoardConfig::ACTIVE.board;
return board == BoardConfig::Board::XteinkX3 || board == BoardConfig::Board::XteinkX4;
+9
View File
@@ -90,9 +90,18 @@ class HalGPIO {
// release frame (alongside wasTouchTap). For tap-vs-long-press decisions.
unsigned long lastTouchHeldMs() const;
// Swipe (flick) gesture on the release frame: writes the start/end positions
// normalized 0..1 in the panel's native frame (map via GfxRenderer::tapToLogical).
// A swipe also raises wasTouchTap(), so check this first. False on non-touch.
bool wasSwipe(float& nxStart, float& nyStart, float& nxEnd, float& nyEnd) const;
// True if a touch controller is present/active (runtime gate; false on the C3).
bool hasTouch() const;
// True if a touch press or release happened this frame (the touch analogue of
// wasAnyPressed/Released), for resetting idle/sleep timers and CPU frequency.
bool wasTouchActivity() const;
// Setup wake up GPIO and enter deep sleep
void startDeepSleep();
+18
View File
@@ -2,6 +2,8 @@
#include <GfxRenderer.h>
#include <cstdlib>
#include "CrossPointSettings.h"
#include "components/TouchRegistry.h"
@@ -116,6 +118,22 @@ bool MappedInputManager::wasCoverTapped(int& id) const {
return TouchRegistry::getInstance().hitTest(lx, ly, TouchRegistry::Cover, id);
}
MappedInputManager::SwipeDir MappedInputManager::wasSwipe() const {
float nxs = 0.0f, nys = 0.0f, nxe = 0.0f, nye = 0.0f;
if (!gpio.wasSwipe(nxs, nys, nxe, nye)) return SwipeDir::None;
// Map both endpoints into the logical frame so the direction follows what the
// user sees regardless of panel mount/orientation.
int sx = 0, sy = 0, ex = 0, ey = 0;
renderer.tapToLogical(nxs, nys, sx, sy);
renderer.tapToLogical(nxe, nye, ex, ey);
const int dx = ex - sx;
const int dy = ey - sy;
if (std::abs(dx) >= std::abs(dy)) {
return dx < 0 ? SwipeDir::Left : SwipeDir::Right;
}
return dy < 0 ? SwipeDir::Up : SwipeDir::Down;
}
bool MappedInputManager::wasPressed(const Button button) const {
// A top-left tap fires on the release frame; expose it on Back's press edge too
// so menus that act on wasPressed(Back) also respond. Deliberately NOT folded
+4
View File
@@ -7,6 +7,7 @@ class GfxRenderer;
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };
enum class SwipeDir { None, Left, Right, Up, Down };
struct Labels {
const char* btn1;
@@ -37,6 +38,9 @@ class MappedInputManager {
// (id = item index). Distinct kinds so a screen with both doesn't confuse them.
bool wasTabTapped(int& id) const;
bool wasCoverTapped(int& id) const;
// Swipe direction in the current logical (oriented) frame, or None. A swipe also
// raises the tap helpers above, so check this first and consume it.
SwipeDir wasSwipe() const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
+6 -4
View File
@@ -268,11 +268,13 @@ void HomeActivity::render(RenderLock&&) {
menuIcons.insert(menuIcons.begin(), Book);
}
// Menu fills the space between the cover and the bottom reserve (button hints
// when shown, else a margin). Sizing it to the real remaining height lets
// drawButtonMenu fit the items, so a scaled-up menu never runs off the bottom.
const int menuY = metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset;
const int bottomReserve = (BaseTheme::showButtonHints() ? metrics.buttonHintsHeight : 0) + metrics.verticalSpacing;
GUI.drawButtonMenu(
renderer,
Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth,
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing +
metrics.homeMenuTopOffset + metrics.buttonHintsHeight)},
renderer, Rect{0, menuY, pageWidth, pageHeight - menuY - bottomReserve},
static_cast<int>(menuItems.size()),
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(),
[&menuItems](int index) { return std::string(menuItems[index]); },
+20 -3
View File
@@ -115,17 +115,34 @@ void SettingsActivity::onExit() {
void SettingsActivity::loop() {
bool hasChangedCategory = false;
// A horizontal swipe pages categories (and the tab bar slides to keep the new one
// visible). Handled first, and it suppresses the row/tab tap handlers below so the
// swipe's release isn't also read as a tap.
const MappedInputManager::SwipeDir swipe = mappedInput.wasSwipe();
const bool swiped = swipe != MappedInputManager::SwipeDir::None;
if (swipe == MappedInputManager::SwipeDir::Left) {
selectedCategoryIndex = ButtonNavigator::nextIndex(selectedCategoryIndex, categoryCount);
selectedSettingIndex = 0;
hasChangedCategory = true;
requestUpdate();
} else if (swipe == MappedInputManager::SwipeDir::Right) {
selectedCategoryIndex = ButtonNavigator::previousIndex(selectedCategoryIndex, categoryCount);
selectedSettingIndex = 0;
hasChangedCategory = true;
requestUpdate();
}
// Tap a settings row to select + activate it. Row 0 is the tab bar, so the list
// is drawn at selectedSettingIndex - 1; map the tapped row back by +1. Touch-down
// shows it selected; release toggles/activates below.
int downId = -1;
if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < settingsCount) {
if (!swiped && mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < settingsCount) {
selectedSettingIndex = downId + 1;
requestUpdate();
}
int tappedId = -1;
if (mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < settingsCount) {
if (!swiped && mappedInput.wasItemTapped(tappedId) && tappedId >= 0 && tappedId < settingsCount) {
selectedSettingIndex = tappedId + 1;
toggleCurrentSetting();
requestUpdate();
@@ -135,7 +152,7 @@ void SettingsActivity::loop() {
// A tap on a category tab switches to it (keeps focus on the tab row); the
// hasChangedCategory block below swaps in that category's settings list.
int tabId = -1;
if (mappedInput.wasTabTapped(tabId) && tabId >= 0 && tabId < categoryCount) {
if (!swiped && mappedInput.wasTabTapped(tabId) && tabId >= 0 && tabId < categoryCount) {
selectedCategoryIndex = tabId;
selectedSettingIndex = 0;
hasChangedCategory = true;
+69 -4
View File
@@ -1,9 +1,11 @@
#include "UITheme.h"
#include <BoardConfig.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <cmath>
#include <memory>
#include "MappedInputManager.h"
@@ -15,6 +17,66 @@
UITheme UITheme::instance;
float UITheme::uiScale() { return BoardConfig::ACTIVE.uiScale; }
namespace {
// Round a pixel dimension by the UI scale.
int sp(int v, float s) { return static_cast<int>(std::lround(v * s)); }
} // namespace
ThemeMetrics scaleThemeMetrics(const ThemeMetrics& b, float s) {
ThemeMetrics m = b;
if (s == 1.0f) return m;
m.batteryWidth = sp(b.batteryWidth, s);
m.batteryHeight = sp(b.batteryHeight, s);
m.topPadding = sp(b.topPadding, s);
m.batteryBarHeight = sp(b.batteryBarHeight, s);
m.headerHeight = sp(b.headerHeight, s);
m.verticalSpacing = sp(b.verticalSpacing, s);
m.contentSidePadding = sp(b.contentSidePadding, s);
m.listRowHeight = sp(b.listRowHeight, s);
m.listWithSubtitleRowHeight = sp(b.listWithSubtitleRowHeight, s);
m.menuRowHeight = sp(b.menuRowHeight, s);
m.menuSpacing = sp(b.menuSpacing, s);
m.tabSpacing = sp(b.tabSpacing, s);
m.tabBarHeight = sp(b.tabBarHeight, s);
m.scrollBarWidth = sp(b.scrollBarWidth, s);
m.scrollBarRightOffset = sp(b.scrollBarRightOffset, s);
m.homeTopPadding = sp(b.homeTopPadding, s);
// homeCoverHeight / homeCoverTileHeight are intentionally NOT scaled: the home
// screen fills the fixed panel height exactly, so scaling the decorative cover
// would push the menu off the bottom. The cover stays native; the menu (a touch
// target) scales and is fit into the remaining space by drawButtonMenu.
m.homeMenuTopOffset = sp(b.homeMenuTopOffset, s);
m.buttonHintsHeight = sp(b.buttonHintsHeight, s);
m.sideButtonHintsWidth = sp(b.sideButtonHintsWidth, s);
m.progressBarHeight = sp(b.progressBarHeight, s);
m.progressBarMarginTop = sp(b.progressBarMarginTop, s);
m.statusBarHorizontalMargin = sp(b.statusBarHorizontalMargin, s);
m.statusBarVerticalMargin = sp(b.statusBarVerticalMargin, s);
m.keyboardKeyWidth = sp(b.keyboardKeyWidth, s);
m.keyboardKeyHeight = sp(b.keyboardKeyHeight, s);
m.keyboardKeySpacing = sp(b.keyboardKeySpacing, s);
m.keyboardBottomKeyHeight = sp(b.keyboardBottomKeyHeight, s);
m.keyboardBottomKeySpacing = sp(b.keyboardBottomKeySpacing, s);
m.keyboardVerticalOffset = sp(b.keyboardVerticalOffset, s);
m.keyboardKeyCornerRadius = sp(b.keyboardKeyCornerRadius, s);
m.keyboardSecondaryLabelRightPadding = sp(b.keyboardSecondaryLabelRightPadding, s);
m.keyboardSecondaryLabelTopPadding = sp(b.keyboardSecondaryLabelTopPadding, s);
m.keyboardMinArrowHeadSize = sp(b.keyboardMinArrowHeadSize, s);
m.popupMarginX = sp(b.popupMarginX, s);
m.popupMarginY = sp(b.popupMarginY, s);
m.popupFrameThickness = sp(b.popupFrameThickness, s);
m.popupCornerRadius = sp(b.popupCornerRadius, s);
m.popupTextBaselineOffsetY = sp(b.popupTextBaselineOffsetY, s);
m.popupProgressBarHeight = sp(b.popupProgressBarHeight, s);
m.textFieldHorizontalPadding = sp(b.textFieldHorizontalPadding, s);
m.textFieldNormalThickness = sp(b.textFieldNormalThickness, s);
m.textFieldCursorThickness = sp(b.textFieldCursorThickness, s);
m.textFieldLineEndOffset = sp(b.textFieldLineEndOffset, s);
return m;
}
UITheme::UITheme() {
auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme);
setTheme(themeType);
@@ -26,28 +88,31 @@ void UITheme::reload() {
}
void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
const ThemeMetrics* base = &BaseMetrics::values;
switch (type) {
case CrossPointSettings::UI_THEME::CLASSIC:
LOG_DBG("UI", "Using Classic theme");
currentTheme = std::make_unique<BaseTheme>();
currentMetrics = &BaseMetrics::values;
base = &BaseMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA:
LOG_DBG("UI", "Using Lyra theme");
currentTheme = std::make_unique<LyraTheme>();
currentMetrics = &LyraMetrics::values;
base = &LyraMetrics::values;
break;
case CrossPointSettings::UI_THEME::ROUNDEDRAFF:
LOG_DBG("UI", "Using RoundedRaff theme");
currentTheme = std::make_unique<RoundedRaffTheme>();
currentMetrics = &RoundedRaffMetrics::values;
base = &RoundedRaffMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA_3_COVERS:
LOG_DBG("UI", "Using Lyra 3 Covers theme");
currentTheme = std::make_unique<Lyra3CoversTheme>();
currentMetrics = &Lyra3CoversMetrics::values;
base = &Lyra3CoversMetrics::values;
break;
}
scaledMetrics = scaleThemeMetrics(*base, uiScale());
currentMetrics = &scaledMetrics;
}
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
+12
View File
@@ -31,10 +31,22 @@ class UITheme {
static int getStatusBarHeight();
static int getProgressBarHeight();
// Per-board UI scale (BoardConfig::ACTIVE.uiScale): 1.0 on button devices, >1 on
// touch devices so chrome is finger-sized. Applied to the metrics below.
static float uiScale();
private:
const ThemeMetrics* currentMetrics;
// Scaled copy of the active theme's constexpr metrics (scaled by uiScale() in
// setTheme()). currentMetrics points here so getMetrics() returns scaled values.
ThemeMetrics scaledMetrics{};
std::unique_ptr<BaseTheme> currentTheme;
};
// Scale a theme's pixel-dimension metrics by `scale` (counts, percents, ratios,
// and bools are left untouched). At scale 1.0 it is an exact copy (no drift).
// Shared by UITheme (active-theme metrics) and each theme's own draw code.
ThemeMetrics scaleThemeMetrics(const ThemeMetrics& base, float scale);
// Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme()
+89 -49
View File
@@ -22,6 +22,14 @@ constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
// This theme's metrics scaled by the board uiScale (lazily built once; uiScale is
// fixed at boot). Runtime draw code reads these so the UI grows on touch/high-PPI
// boards. constexpr button-hint locals keep BaseMetrics::values (hints are off on
// touch, so they need no scaling and must stay compile-time).
const ThemeMetrics& M() {
static const ThemeMetrics m = scaleThemeMetrics(BaseMetrics::values, UITheme::uiScale());
return m;
}
} // namespace
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
@@ -101,7 +109,10 @@ void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
// Vertically center the percentage on the battery icon: place the text so its
// optical center (from the real font metrics) sits at the icon's center.
const int textY = y + rect.height / 2 - renderer.getTextVisualCenterOffset(SMALL_FONT_ID);
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, textY, percentageText.c_str());
}
const Rect iconRect{rect.x, y, rect.width, rect.height};
@@ -253,7 +264,7 @@ void BaseTheme::drawSideButtonHintsImpl(const GfxRenderer& renderer, const char*
}
int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int rowHeight = (hasSubtitle) ? M().listWithSubtitleRowHeight : M().listRowHeight;
return contentHeight / rowHeight;
}
@@ -264,7 +275,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
(rowSubtitle != nullptr) ? M().listWithSubtitleRowHeight : M().listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
@@ -306,7 +317,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const int itemY = rect.y + (i % pageItems) * rowHeight;
TouchRegistry::getInstance().add(Rect{rect.x, itemY - 2, rect.width, rowHeight}, i, TouchRegistry::Item);
int rowTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
int rowTextWidth = contentWidth - M().contentSidePadding * 2;
std::string valueText;
if (rowValue != nullptr) {
valueText = rowValue(i);
@@ -321,13 +332,13 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
auto itemName = rowTitle(i);
auto font = UI_10_FONT_ID;
auto item = renderer.truncatedText(font, itemName.c_str(), rowTextWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
renderer.drawText(font, rect.x + M().contentSidePadding, itemY, item.c_str(), i != selectedIndex);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(font, item.c_str());
const int lineH = renderer.getLineHeight(font);
const int tx = rect.x + BaseMetrics::values.contentSidePadding;
const int tx = rect.x + M().contentSidePadding;
for (int py = itemY; py < itemY + lineH; py++)
for (int px = tx; px < tx + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
@@ -337,7 +348,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
std::string subtitleText = rowSubtitle(i);
if (!subtitleText.empty()) {
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 22, subtitle.c_str(),
renderer.drawText(SMALL_FONT_ID, rect.x + M().contentSidePadding, itemY + 22, subtitle.c_str(),
i != selectedIndex);
}
}
@@ -348,7 +359,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
if (rowSubtitle != nullptr) {
valueY = itemY + 10;
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - M().contentSidePadding - valueTextWidth,
valueY, valueText.c_str(), i != selectedIndex);
}
}
@@ -361,30 +372,30 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
// Hide last battery draw
constexpr int maxBatteryWidth = 80;
renderer.fillRect(rect.x + rect.width - maxBatteryWidth, rect.y + 5, maxBatteryWidth,
BaseMetrics::values.batteryHeight + 10, false);
M().batteryHeight + 10, false);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// Position icon at right edge, drawBatteryRight will place text to the left
const int batteryX = rect.x + rect.width - 12 - BaseMetrics::values.batteryWidth;
const int batteryX = rect.x + rect.width - 12 - M().batteryWidth;
drawBatteryRight(renderer,
Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
Rect{batteryX, rect.y + 5, M().batteryWidth, M().batteryHeight},
showBatteryPercentage);
if (title) {
int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth;
int padding = rect.width - batteryX + M().batteryWidth;
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2,
rect.width - padding * 2 - M().contentSidePadding * 2,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
}
if (subtitle) {
auto truncatedSubtitle = renderer.truncatedText(
SMALL_FONT_ID, subtitle, rect.width - BaseMetrics::values.contentSidePadding * 2, EpdFontFamily::REGULAR);
SMALL_FONT_ID, subtitle, rect.width - M().contentSidePadding * 2, EpdFontFamily::REGULAR);
int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str());
renderer.drawText(SMALL_FONT_ID,
rect.x + rect.width - BaseMetrics::values.contentSidePadding - truncatedSubtitleWidth, subtitleY,
rect.x + rect.width - M().contentSidePadding - truncatedSubtitleWidth, subtitleY,
truncatedSubtitle.c_str(), true);
}
}
@@ -392,55 +403,80 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
void BaseTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const {
constexpr int maxListValueWidth = 200;
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
int rightSpace = BaseMetrics::values.contentSidePadding;
int currentX = rect.x + M().contentSidePadding;
int rightSpace = M().contentSidePadding;
if (rightLabel) {
auto truncatedRightLabel =
renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR);
int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - BaseMetrics::values.contentSidePadding - rightLabelWidth,
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - M().contentSidePadding - rightLabelWidth,
rect.y + 7, truncatedRightLabel.c_str());
rightSpace += rightLabelWidth + 10;
}
auto truncatedLabel = renderer.truncatedText(
UI_12_FONT_ID, label, rect.width - BaseMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
UI_12_FONT_ID, label, rect.width - M().contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR);
}
int BaseTheme::tabBarScrollOffset(int contentWidth, int selStart, int selWidth, int availWidth) {
if (contentWidth <= availWidth) return 0; // fits, no scroll
int offset = selStart - (availWidth - selWidth) / 2; // center the selected tab
const int maxOffset = contentWidth - availWidth;
if (offset < 0) offset = 0;
if (offset > maxOffset) offset = maxOffset;
return offset;
}
void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const {
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int pad = M().contentSidePadding;
const int spacing = M().tabSpacing;
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
// Measure pass: total row width and the selected tab's position, so the row can
// slide to keep the selected tab on screen when scaled tabs overflow the width.
int contentWidth = 0, selStart = 0, selWidth = 0;
for (const auto& tab : tabs) {
const int w =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
if (tab.selected) {
selStart = contentWidth;
selWidth = w;
}
contentWidth += w + spacing;
}
const int scroll = tabBarScrollOffset(contentWidth, selStart, selWidth, rect.width - pad * 2);
int currentX = rect.x + pad - scroll;
int tabIndex = 0;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
const int idx = tabIndex++;
// Tap target spans the label plus the inter-tab gap, full bar height.
TouchRegistry::getInstance().add(
Rect{currentX - 3, rect.y, textWidth + BaseMetrics::values.tabSpacing, rect.height}, tabIndex++,
TouchRegistry::Tab);
// Skip tabs scrolled fully off the bar (don't register unreachable tap targets).
if (currentX + textWidth > rect.x && currentX < rect.x + rect.width) {
// Tap target spans the label plus the inter-tab gap, full bar height.
TouchRegistry::getInstance().add(Rect{currentX - 3, rect.y, textWidth + spacing, rect.height}, idx,
TouchRegistry::Tab);
// Draw underline for selected tab
if (tab.selected) {
if (selected) {
renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap);
} else {
renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight);
if (tab.selected) {
if (selected) {
renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap);
} else {
renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight);
}
}
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected),
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected),
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
currentX += textWidth + BaseMetrics::values.tabSpacing;
currentX += textWidth + spacing;
}
}
@@ -463,7 +499,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
// Try to get actual image dimensions from BMP header
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, M().homeCoverHeight);
HalFile file;
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
@@ -517,7 +553,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, M().homeCoverHeight);
// First time: load cover from SD and render
HalFile file;
@@ -682,22 +718,27 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const {
// Fit all buttons into rect.height (after the top pad): clamp the stride when the
// scaled rows would overflow, so a scaled-up menu never runs off the bottom.
const int topPad = M().verticalSpacing;
const int sidePad = M().contentSidePadding;
int stride = M().menuRowHeight + M().menuSpacing;
if (buttonCount > 0 && topPad + stride * buttonCount > rect.height) {
stride = (rect.height - topPad) / buttonCount;
}
const int rowHeight = std::max(1, stride - M().menuSpacing);
for (int i = 0; i < buttonCount; ++i) {
const int tileY = BaseMetrics::values.verticalSpacing + rect.y +
static_cast<int>(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing);
TouchRegistry::getInstance().add(
Rect{rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight},
i, TouchRegistry::Item);
const int tileY = topPad + rect.y + static_cast<int>(i) * stride;
TouchRegistry::getInstance().add(Rect{rect.x + sidePad, tileY, rect.width - sidePad * 2, rowHeight}, i,
TouchRegistry::Item);
const bool selected = selectedIndex == i;
if (selected) {
renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
renderer.fillRect(rect.x + sidePad, tileY, rect.width - sidePad * 2, rowHeight);
} else {
renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
renderer.drawRect(rect.x + sidePad, tileY, rect.width - sidePad * 2, rowHeight);
}
std::string labelStr = buttonLabel(i);
@@ -705,8 +746,7 @@ void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = rect.x + (rect.width - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY =
tileY + (BaseMetrics::values.menuRowHeight - lineHeight) / 2; // vertically centered assuming y is top of text
const int textY = tileY + (rowHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, selectedIndex != i);
}
+6
View File
@@ -231,6 +231,12 @@ class BaseTheme {
bool inactiveSelection = false) const;
virtual bool showsFileIcons() const { return false; }
// Horizontal scroll offset (px to subtract from tab X positions) that keeps the
// selected tab visible when the tab row is wider than availWidth — used by every
// theme's drawTabBar so scaled-up tabs slide instead of running off-screen.
// Returns 0 when the whole row fits.
static int tabBarScrollOffset(int contentWidth, int selStart, int selWidth, int availWidth);
// Shared constants and helpers for battery drawing (used by all themes)
static constexpr int batteryPercentSpacing = 4;
static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight);
+20 -15
View File
@@ -14,6 +14,11 @@
// Internal constants
namespace {
// This theme's metrics scaled by the board uiScale (see BaseTheme.cpp M()).
const ThemeMetrics& M() {
static const ThemeMetrics m = scaleThemeMetrics(Lyra3CoversMetrics::values, UITheme::uiScale());
return m;
}
constexpr int hPaddingInSelection = 8;
constexpr int cornerRadius = 6;
} // namespace
@@ -21,7 +26,7 @@ constexpr int cornerRadius = 6;
void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3;
const int tileWidth = (rect.width - 2 * M().contentSidePadding) / 3;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
@@ -31,15 +36,15 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
if (hasContinueReading) {
if (!coverRendered) {
for (int i = 0;
i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); i++) {
i < std::min(static_cast<int>(recentBooks.size()), M().homeRecentBooksCount); i++) {
std::string coverPath = recentBooks[i].coverBmpPath;
bool hasCover = true;
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
int tileX = M().contentSidePadding + tileWidth * i;
if (coverPath.empty()) {
hasCover = false;
} else {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(coverPath, Lyra3CoversMetrics::values.homeCoverHeight);
UITheme::getCoverThumbPath(coverPath, M().homeCoverHeight);
// First time: load cover from SD and render
HalFile file;
@@ -50,11 +55,11 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
float coverWidth = static_cast<float>(bitmap.getWidth());
float ratio = coverWidth / coverHeight;
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
static_cast<float>(Lyra3CoversMetrics::values.homeCoverHeight);
static_cast<float>(M().homeCoverHeight);
float cropX = 1.0f - (tileRatio / ratio);
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight,
tileWidth - 2 * hPaddingInSelection, M().homeCoverHeight,
cropX);
} else {
hasCover = false;
@@ -64,13 +69,13 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
}
// Draw either way
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection,
Lyra3CoversMetrics::values.homeCoverHeight, true);
M().homeCoverHeight, true);
if (!hasCover) {
// Render empty cover
renderer.fillRect(tileX + hPaddingInSelection,
tileY + hPaddingInSelection + (Lyra3CoversMetrics::values.homeCoverHeight / 3),
tileWidth - 2 * hPaddingInSelection, 2 * Lyra3CoversMetrics::values.homeCoverHeight / 3,
tileY + hPaddingInSelection + (M().homeCoverHeight / 3),
tileWidth - 2 * hPaddingInSelection, 2 * M().homeCoverHeight / 3,
true);
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
}
@@ -80,11 +85,11 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer
}
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount);
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), M().homeRecentBooksCount);
i++) {
bool bookSelected = (selectorIndex == i);
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
int tileX = M().contentSidePadding + tileWidth * i;
const int maxLineWidth = tileWidth - 2 * hPaddingInSelection;
@@ -100,15 +105,15 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
M().homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection,
hPaddingInSelection, M().homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + M().homeCoverHeight + hPaddingInSelection,
tileWidth, dynamicTitleBoxHeight, cornerRadius, false, false, true, true,
Color::LightGray);
}
int currentY = tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection + 5;
int currentY = tileY + M().homeCoverHeight + hPaddingInSelection + 5;
for (const auto& line : titleLines) {
renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, currentY, line.c_str(), true);
currentY += titleLineHeight;
+109 -66
View File
@@ -6,6 +6,7 @@
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>
@@ -32,6 +33,11 @@
// Internal constants
namespace {
// This theme's metrics scaled by the board uiScale (see BaseTheme.cpp M()).
const ThemeMetrics& M() {
static const ThemeMetrics m = scaleThemeMetrics(LyraMetrics::values, UITheme::uiScale());
return m;
}
constexpr int hPaddingInSelection = 8;
constexpr int cornerRadius = 6;
constexpr int topHintButtonY = 345;
@@ -83,6 +89,14 @@ const uint8_t* iconForName(UIIcon icon, int size) {
}
return nullptr;
}
// Top-left Y to box-center an icon on a line of text whose top is at textTop. The
// UI icons are already centered in their boxes, so this just box-centers the icon
// on the text's optical middle, which the renderer derives from the real font
// metrics (x-height) — no per-size fraction to tweak.
int iconYForText(const GfxRenderer& renderer, int fontId, int textTop, int boxSize) {
return textTop + renderer.getTextVisualCenterOffset(fontId) - boxSize / 2;
}
} // namespace
void LyraTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
@@ -112,9 +126,9 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// Position icon at right edge, drawBatteryRight will place text to the left
const int batteryX = rect.x + rect.width - 12 - LyraMetrics::values.batteryWidth;
const int batteryX = rect.x + rect.width - 12 - M().batteryWidth;
drawBatteryRight(renderer,
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
Rect{batteryX, rect.y + 5, M().batteryWidth, M().batteryHeight},
showBatteryPercentage);
int maxTitleWidth = title != nullptr ? renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD) : 0;
@@ -122,7 +136,7 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
subtitle != nullptr ? renderer.getTextWidth(SMALL_FONT_ID, subtitle, EpdFontFamily::REGULAR) : 0;
// Available space is the distance between the side paddings, and a with side padding between title and subtitle.
const int availableSpace = rect.width - LyraMetrics::values.contentSidePadding * 3;
const int availableSpace = rect.width - M().contentSidePadding * 3;
if (maxTitleWidth + maxSubtitleWidth > availableSpace) {
if ((maxTitleWidth > availableSpace / 2) && (maxSubtitleWidth > availableSpace / 2)) {
@@ -141,8 +155,8 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
if (title) {
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, maxTitleWidth, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
renderer.drawText(UI_12_FONT_ID, rect.x + M().contentSidePadding,
rect.y + M().batteryBarHeight + 3, truncatedTitle.c_str(), true,
EpdFontFamily::BOLD);
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width - 1, rect.y + rect.height - 3, 3, true);
}
@@ -151,25 +165,25 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
auto truncatedSubtitle = renderer.truncatedText(SMALL_FONT_ID, subtitle, maxSubtitleWidth, EpdFontFamily::REGULAR);
int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str());
renderer.drawText(SMALL_FONT_ID,
rect.x + rect.width - LyraMetrics::values.contentSidePadding - truncatedSubtitleWidth,
rect.x + rect.width - M().contentSidePadding - truncatedSubtitleWidth,
rect.y + 50, truncatedSubtitle.c_str(), true);
}
}
void LyraTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
int rightSpace = LyraMetrics::values.contentSidePadding;
int currentX = rect.x + M().contentSidePadding;
int rightSpace = M().contentSidePadding;
if (rightLabel) {
auto truncatedRightLabel =
renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR);
int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - LyraMetrics::values.contentSidePadding - rightLabelWidth,
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - M().contentSidePadding - rightLabelWidth,
rect.y + 7, truncatedRightLabel.c_str());
rightSpace += rightLabelWidth + hPaddingInSelection;
}
auto truncatedLabel = renderer.truncatedText(
UI_10_FONT_ID, label, rect.width - LyraMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
UI_10_FONT_ID, label, rect.width - M().contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
renderer.drawText(UI_10_FONT_ID, currentX, rect.y + 6, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR);
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
@@ -177,43 +191,57 @@ void LyraTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char
void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
const int pad = M().contentSidePadding;
const int spacing = M().tabSpacing;
if (selected) {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
// Measure pass: slide the row to keep the selected tab visible when scaled tabs
// overflow the width (slot = label + horizontal padding).
int contentWidth = 0, selStart = 0, selWidth = 0;
for (const auto& tab : tabs) {
const int slotW = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR) + 2 * hPaddingInSelection;
if (tab.selected) {
selStart = contentWidth;
selWidth = slotW;
}
contentWidth += slotW + spacing;
}
const int scroll = tabBarScrollOffset(contentWidth, selStart, selWidth, rect.width - pad * 2);
int currentX = rect.x + pad - scroll;
int tabIndex = 0;
for (const auto& tab : tabs) {
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR);
const int slotW = textWidth + 2 * hPaddingInSelection;
const int idx = tabIndex++;
TouchRegistry::getInstance().add(
Rect{currentX, rect.y, textWidth + 2 * hPaddingInSelection + LyraMetrics::values.tabSpacing, rect.height},
tabIndex++, TouchRegistry::Tab);
if (currentX + slotW > rect.x && currentX < rect.x + rect.width) {
TouchRegistry::getInstance().add(Rect{currentX, rect.y, slotW + spacing, rect.height}, idx, TouchRegistry::Tab);
if (tab.selected) {
if (selected) {
renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4,
cornerRadius, Color::Black);
} else {
renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3,
Color::LightGray);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
rect.y + rect.height - 3, 2, true);
if (tab.selected) {
if (selected) {
renderer.fillRoundedRect(currentX, rect.y + 1, slotW, rect.height - 4, cornerRadius, Color::Black);
} else {
renderer.fillRectDither(currentX, rect.y, slotW, rect.height - 3, Color::LightGray);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + slotW, rect.y + rect.height - 3, 2, true);
}
}
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label,
!(tab.selected && selected), EpdFontFamily::REGULAR);
}
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected),
EpdFontFamily::REGULAR);
currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection;
currentX += slotW + spacing;
}
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
}
int LyraTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int rowHeight = (hasSubtitle) ? M().listWithSubtitleRowHeight : M().listRowHeight;
return contentHeight / rowHeight;
}
@@ -224,7 +252,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowValue, bool highlightValue,
const std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
(rowSubtitle != nullptr) ? M().listWithSubtitleRowHeight : M().listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
@@ -235,34 +263,40 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
const int currentPage = selectedIndex / pageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
const int scrollBarX = rect.x + rect.width - M().scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth,
renderer.fillRect(scrollBarX - M().scrollBarWidth, scrollBarY, M().scrollBarWidth,
scrollBarHeight, true);
}
// Draw selection
int contentWidth =
rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
(totalPages > 1 ? (M().scrollBarWidth + M().scrollBarRightOffset) : 1);
if (selectedIndex >= 0) {
renderer.fillRoundedRect(
rect.x + LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, Color::LightGray);
rect.x + M().contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - M().contentSidePadding * 2, rowHeight, cornerRadius, Color::LightGray);
}
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize;
int textX = rect.x + M().contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - M().contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize = 0;
if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection;
textWidth -= iconSize + hPaddingInSelection;
}
// Title baseline-top offset within the row. Single-line rows center the title
// (and its icon) vertically in the scaled row via the real font metrics, so the
// row never looks top-heavy as it scales. Rows with a subtitle keep the stacked
// layout (title near the top, subtitle below).
const int titleOffset =
(rowSubtitle != nullptr) ? 7 : rowHeight / 2 - renderer.getTextVisualCenterOffset(UI_10_FONT_ID);
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
TouchRegistry::getInstance().add(Rect{rect.x, itemY, rect.width, rowHeight}, i, TouchRegistry::Item);
@@ -278,15 +312,16 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
rowTextWidth -= valueWidth;
}
const int titleTop = itemY + titleOffset;
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
renderer.drawText(UI_10_FONT_ID, textX, titleTop, item.c_str(), true);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(UI_10_FONT_ID, item.c_str());
const int lineH = renderer.getLineHeight(UI_10_FONT_ID);
for (int py = itemY + 7; py < itemY + 7 + lineH; py++)
for (int py = titleTop; py < titleTop + lineH; py++)
for (int px = textX; px < textX + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
@@ -295,8 +330,9 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
itemY + iconY, iconSize, iconSize);
const int iconDrawY = iconYForText(renderer, UI_10_FONT_ID, titleTop, iconSize);
renderer.drawIcon(iconBitmap, rect.x + M().contentSidePadding + hPaddingInSelection, iconDrawY, iconSize,
iconSize);
}
}
@@ -311,7 +347,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
rect.x + contentWidth - M().contentSidePadding - hPaddingInSelection - valueWidth, itemY,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
}
@@ -319,7 +355,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
if (rowSubtitle != nullptr) {
valueY = itemY + 16;
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - M().contentSidePadding - valueWidth,
valueY, valueText.c_str(), !(i == selectedIndex && highlightValue));
}
}
@@ -416,20 +452,20 @@ void LyraTheme::drawSideButtonHintsImpl(const GfxRenderer& renderer, const char*
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
const int tileWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
const int tileWidth = rect.width - 2 * M().contentSidePadding;
const int tileHeight = rect.height;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
if (coverWidth == 0) {
coverWidth = LyraMetrics::values.homeCoverHeight * 0.6;
coverWidth = M().homeCoverHeight * 0.6;
}
// Tapping anywhere on the continue-reading card (cover + title/gray area) opens
// recentBooks[0] (home selector 0) — full card width, not just the cover photo.
if (hasContinueReading) {
TouchRegistry::getInstance().add(
Rect{LyraMetrics::values.contentSidePadding, tileY, tileWidth,
LyraMetrics::values.homeCoverHeight + 2 * hPaddingInSelection},
Rect{M().contentSidePadding, tileY, tileWidth,
M().homeCoverHeight + 2 * hPaddingInSelection},
0, TouchRegistry::Cover);
}
@@ -441,11 +477,11 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
if (!coverRendered) {
std::string coverPath = book.coverBmpPath;
bool hasCover = true;
int tileX = LyraMetrics::values.contentSidePadding;
int tileX = M().contentSidePadding;
if (coverPath.empty()) {
hasCover = false;
} else {
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, M().homeCoverHeight);
// First time: load cover from SD and render
HalFile file;
@@ -454,7 +490,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
coverWidth = bitmap.getWidth();
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
LyraMetrics::values.homeCoverHeight);
M().homeCoverHeight);
} else {
hasCover = false;
}
@@ -464,13 +500,13 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
// Draw either way
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
LyraMetrics::values.homeCoverHeight, true);
M().homeCoverHeight, true);
if (!hasCover) {
// Render empty cover
renderer.fillRect(tileX + hPaddingInSelection,
tileY + hPaddingInSelection + (LyraMetrics::values.homeCoverHeight / 3), coverWidth,
2 * LyraMetrics::values.homeCoverHeight / 3, true);
tileY + hPaddingInSelection + (M().homeCoverHeight / 3), coverWidth,
2 * M().homeCoverHeight / 3, true);
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
}
@@ -480,19 +516,19 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
bool bookSelected = (selectorIndex == 0);
int tileX = LyraMetrics::values.contentSidePadding;
int textWidth = tileWidth - 2 * hPaddingInSelection - LyraMetrics::values.verticalSpacing - coverWidth;
int tileX = M().contentSidePadding;
int textWidth = tileWidth - 2 * hPaddingInSelection - M().verticalSpacing - coverWidth;
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, Color::LightGray);
M().homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + hPaddingInSelection + coverWidth, tileY + hPaddingInSelection,
tileWidth - hPaddingInSelection - coverWidth, LyraMetrics::values.homeCoverHeight,
tileWidth - hPaddingInSelection - coverWidth, M().homeCoverHeight,
Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
renderer.fillRoundedRect(tileX, tileY + M().homeCoverHeight + hPaddingInSelection, tileWidth,
hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray);
}
@@ -504,7 +540,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
const int authorHeight = book.author.empty() ? 0 : (renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2);
const int totalBlockHeight = titleBlockHeight + authorHeight;
int titleY = tileY + tileHeight / 2 - totalBlockHeight / 2;
const int textX = tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing;
const int textX = tileX + hPaddingInSelection + coverWidth + M().verticalSpacing;
for (const auto& line : titleLines) {
renderer.drawText(UI_12_FONT_ID, textX, titleY, line.c_str(), true, EpdFontFamily::BOLD);
titleY += titleLineHeight;
@@ -529,11 +565,17 @@ void LyraTheme::drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) c
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const {
// Fit all buttons into rect.height: clamp the stride (and row height) when the
// scaled rows would overflow, so a scaled-up menu never runs off the bottom.
int stride = M().menuRowHeight + M().menuSpacing;
if (buttonCount > 0 && stride * buttonCount > rect.height) {
stride = rect.height / buttonCount;
}
const int rowHeight = std::max(1, stride - M().menuSpacing);
for (int i = 0; i < buttonCount; ++i) {
int tileWidth = rect.width - LyraMetrics::values.contentSidePadding * 2;
Rect tileRect = Rect{rect.x + LyraMetrics::values.contentSidePadding,
rect.y + i * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), tileWidth,
LyraMetrics::values.menuRowHeight};
int tileWidth = rect.width - M().contentSidePadding * 2;
Rect tileRect = Rect{rect.x + M().contentSidePadding, rect.y + i * stride, tileWidth, rowHeight};
TouchRegistry::getInstance().add(tileRect, i, TouchRegistry::Item);
const bool selected = selectedIndex == i;
@@ -546,13 +588,14 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
const char* label = labelStr.c_str();
int textX = tileRect.x + 16;
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2;
const int textY = tileRect.y + (rowHeight - lineHeight) / 2;
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, mainMenuIconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, textX, textY + 3, mainMenuIconSize, mainMenuIconSize);
const int iconY = iconYForText(renderer, UI_12_FONT_ID, textY, mainMenuIconSize);
renderer.drawIcon(iconBitmap, textX, iconY, mainMenuIconSize, mainMenuIconSize);
textX += mainMenuIconSize + hPaddingInSelection + 2;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
.menuRowHeight = 64,
.menuSpacing = 8,
.menuSpacing = 14,
.tabSpacing = 8,
.tabBarHeight = 40,
.scrollBarWidth = 4,
@@ -15,6 +15,11 @@
#include "fontIds.h"
namespace {
// This theme's metrics scaled by the board uiScale (see BaseTheme.cpp M()).
const ThemeMetrics& M() {
static const ThemeMetrics m = scaleThemeMetrics(RoundedRaffMetrics::values, UITheme::uiScale());
return m;
}
constexpr int kCoverRadius = 18;
constexpr int kMenuRadius = 30;
constexpr int kBottomRadius = 15;
@@ -30,8 +35,8 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa
return;
}
const int barW = RoundedRaffMetrics::values.scrollBarWidth;
const int barX = rect.x + rect.width - RoundedRaffMetrics::values.scrollBarRightOffset - barW;
const int barW = M().scrollBarWidth;
const int barX = rect.x + rect.width - M().scrollBarRightOffset - barW;
const int barY = rect.y;
const int barH = rect.height;
@@ -54,13 +59,13 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const
if (title == nullptr) {
return;
}
const int sidePadding = RoundedRaffMetrics::values.contentSidePadding;
const int sidePadding = M().contentSidePadding;
const int titleX = rect.x + sidePadding;
const int titleY = rect.y + 14;
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth;
const int batteryIconX = rect.x + rect.width - sidePadding - M().batteryWidth;
// Reserve space for the widest possible percentage text to avoid title/battery overlap
int batteryGroupLeftX = batteryIconX;
@@ -69,8 +74,8 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const
const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%");
batteryGroupLeftX -= maxTextWidth + batteryPercentSpacing;
const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth;
const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8);
const int clearW = maxTextWidth + batteryPercentSpacing + M().batteryWidth;
const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), M().batteryHeight + 8);
renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false);
}
@@ -78,8 +83,8 @@ void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const
auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTitleWidth, EpdFontFamily::BOLD);
renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD);
drawBatteryRight(renderer,
Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth,
RoundedRaffMetrics::values.batteryHeight},
Rect{batteryIconX, rect.y + 14, M().batteryWidth,
M().batteryHeight},
showBatteryPercentage);
}
@@ -119,20 +124,20 @@ void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const
void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
const int tileWidth = rect.width - 2 * RoundedRaffMetrics::values.contentSidePadding;
const int tileWidth = rect.width - 2 * M().contentSidePadding;
const int tileHeight = rect.height;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
if (coverWidth == 0) {
coverWidth = RoundedRaffMetrics::values.homeCoverHeight * 0.6;
coverWidth = M().homeCoverHeight * 0.6;
}
const int imgY = tileY + (tileHeight - RoundedRaffMetrics::values.homeCoverHeight) / 2;
const int tileX = RoundedRaffMetrics::values.contentSidePadding;
const int imgY = tileY + (tileHeight - M().homeCoverHeight) / 2;
const int tileX = M().contentSidePadding;
// Tapping the continue-reading cover opens recentBooks[0] (home selector 0).
if (hasContinueReading) {
TouchRegistry::getInstance().add(
Rect{tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, RoundedRaffMetrics::values.homeCoverHeight}, 0,
Rect{tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, M().homeCoverHeight}, 0,
TouchRegistry::Cover);
}
@@ -148,7 +153,7 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
hasCover = false;
} else {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(coverPath, RoundedRaffMetrics::values.homeCoverHeight);
UITheme::getCoverThumbPath(coverPath, M().homeCoverHeight);
// First time: load cover from SD and render
HalFile file;
@@ -157,9 +162,9 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
coverWidth = bitmap.getWidth();
renderer.drawBitmap(bitmap, tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth,
RoundedRaffMetrics::values.homeCoverHeight);
M().homeCoverHeight);
renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth,
RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius,
M().homeCoverHeight, kCoverRadius,
Color::LightGray);
} else {
hasCover = false;
@@ -170,15 +175,15 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
// Draw either way
renderer.drawRoundedRect(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth,
RoundedRaffMetrics::values.homeCoverHeight, 1, kCoverRadius, true);
M().homeCoverHeight, 1, kCoverRadius, true);
if (!hasCover) {
// Render empty cover
renderer.fillRect(tileX + (tileWidth - coverWidth) / 2, imgY + (RoundedRaffMetrics::values.homeCoverHeight / 3),
coverWidth, 2 * RoundedRaffMetrics::values.homeCoverHeight / 3, true);
renderer.fillRect(tileX + (tileWidth - coverWidth) / 2, imgY + (M().homeCoverHeight / 3),
coverWidth, 2 * M().homeCoverHeight / 3, true);
renderer.drawIcon(CoverIcon, tileX + (tileWidth - coverWidth) / 2 + 24, imgY + 24, 32, 32);
renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth,
RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius,
M().homeCoverHeight, kCoverRadius,
Color::LightGray);
}
@@ -188,12 +193,12 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con
renderer.fillRoundedRect(tileX, tileY, tileWidth, imgY - tileY, kRowRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, RoundedRaffMetrics::values.homeCoverHeight,
renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, M().homeCoverHeight,
Color::LightGray);
renderer.fillRectDither(tileX + (tileWidth + coverWidth) / 2, imgY, (tileWidth - coverWidth) / 2,
RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, imgY + RoundedRaffMetrics::values.homeCoverHeight, tileWidth,
tileHeight - (imgY - tileY + RoundedRaffMetrics::values.homeCoverHeight), kRowRadius,
M().homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, imgY + M().homeCoverHeight, tileWidth,
tileHeight - (imgY - tileY + M().homeCoverHeight), kRowRadius,
false, false, true, true, Color::LightGray);
} else {
renderer.fillRoundedRect(tileX, tileY, tileWidth, tileHeight, kRowRadius, Color::LightGray);
@@ -206,7 +211,7 @@ void RoundedRaffTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int butt
const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const {
(void)rowIcon;
const int sidePadding = RoundedRaffMetrics::values.contentSidePadding;
const int sidePadding = M().contentSidePadding;
const int rowX = rect.x + sidePadding;
const int rowHeight = renderer.getLineHeight(kTitleFontId) + 20; // 10px top + 10px bottom
const int rowGap = kSelectableRowGap;
@@ -330,12 +335,12 @@ void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int item
constexpr int subtitleInterLineGap = 4;
const int subtitleRowHeight =
subtitleTopPadding + titleLineHeight + subtitleInterLineGap + subtitleLineHeight + subtitleBottomPadding;
const int rowHeight = hasSubtitle ? subtitleRowHeight : RoundedRaffMetrics::values.listRowHeight;
const int rowHeight = hasSubtitle ? subtitleRowHeight : M().listRowHeight;
const int rowStep = rowHeight + kSelectableRowGap;
const int pageItems = std::max(1, rect.height / rowStep);
const int pageStartIndex = std::max(0, selectedIndex / pageItems) * pageItems;
const int sidePadding = RoundedRaffMetrics::values.contentSidePadding;
const int sidePadding = M().contentSidePadding;
const int rowX = rect.x + sidePadding;
const int rowWidth = rect.width - sidePadding * 2;
@@ -404,7 +409,7 @@ void RoundedRaffTheme::drawButtonHintsImpl(GfxRenderer& renderer, const char* bt
const int sidePadding = 20;
const int groupGap = 10;
const int bottomMargin = 10;
const int hintHeight = RoundedRaffMetrics::values.buttonHintsHeight - 10; // 30px total guide height
const int hintHeight = M().buttonHintsHeight - 10; // 30px total guide height
const int groupWidth = (pageWidth - sidePadding * 2 - groupGap) / 2;
const int hintY = pageHeight - hintHeight - bottomMargin;
const int textY = hintY + (hintHeight - renderer.getLineHeight(kGuideFontId)) / 2;
+32 -2
View File
@@ -1,5 +1,7 @@
#include <Arduino.h>
#include <Epub.h>
#include <cmath>
#include <FontCacheManager.h>
#include <FontDecompressor.h>
#include <GfxRenderer.h>
@@ -297,6 +299,34 @@ void setupDisplayAndFonts(bool seamless = false) {
renderer.insertFont(UI_12_FONT_ID, ui12FontFamily);
renderer.insertFont(SMALL_FONT_ID, smallFontFamily);
// UI chrome font scaling on high-density / touch boards: Ubuntu UI is only built
// at 10/12pt, so remap the small UI fonts up the Noto Sans ladder to grow chrome
// text with uiScale. Reader body fonts aren't remapped, so book text is unchanged.
if (const float s = UITheme::uiScale(); s > 1.05f) {
auto nearestNotoSans = [](float pt) -> int {
const struct {
float pt;
int id;
} sizes[] = {{12, NOTOSANS_12_FONT_ID},
{14, NOTOSANS_14_FONT_ID},
{16, NOTOSANS_16_FONT_ID},
{18, NOTOSANS_18_FONT_ID}};
int best = sizes[0].id;
float bestDiff = 1e9f;
for (const auto& z : sizes) {
const float d = std::fabs(z.pt - pt);
if (d < bestDiff) {
bestDiff = d;
best = z.id;
}
}
return best;
};
const int from[3] = {SMALL_FONT_ID, UI_10_FONT_ID, UI_12_FONT_ID};
const int to[3] = {nearestNotoSans(8 * s), nearestNotoSans(10 * s), nearestNotoSans(12 * s)};
renderer.setUiFontRemap(from, to, 3);
}
// Discover and load SD card fonts
sdFontSystem.begin(renderer);
@@ -531,10 +561,10 @@ void loop() {
// Check for any user activity (button press or release) or active background work
static unsigned long lastActivityTime = millis();
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || halTiltSensor.hadActivity() ||
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || gpio.wasTouchActivity() || halTiltSensor.hadActivity() ||
activityManager.preventAutoSleep()) {
lastActivityTime = millis(); // Reset inactivity timer
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user (button or touch) activity
}
static bool screenshotButtonsReleased = true;