From e103cdfd11974f9784e452db59c5b4e836b411f5 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 15 Jun 2026 22:42:33 -0400 Subject: [PATCH] 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(). --- lib/GfxRenderer/GfxRenderer.cpp | 62 ++++++- lib/GfxRenderer/GfxRenderer.h | 20 ++ lib/hal/HalGPIO.cpp | 6 + lib/hal/HalGPIO.h | 9 + src/MappedInputManager.cpp | 18 ++ src/MappedInputManager.h | 4 + src/activities/home/HomeActivity.cpp | 10 +- src/activities/settings/SettingsActivity.cpp | 23 ++- src/components/UITheme.cpp | 73 +++++++- src/components/UITheme.h | 12 ++ src/components/themes/BaseTheme.cpp | 138 +++++++++----- src/components/themes/BaseTheme.h | 6 + .../themes/lyra/Lyra3CoversTheme.cpp | 35 ++-- src/components/themes/lyra/LyraTheme.cpp | 175 +++++++++++------- src/components/themes/lyra/LyraTheme.h | 2 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 61 +++--- src/main.cpp | 34 +++- 17 files changed, 506 insertions(+), 182 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fb084e03..ec8a2529 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -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; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e21675e8..a504ec89 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -51,6 +51,13 @@ class GfxRenderer { uint32_t frameBufferSize = HalDisplay::BUFFER_SIZE; std::vector bwBufferChunks; std::map 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 diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 9f07b1df..4169faca 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -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; diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 63592438..493635ea 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -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(); diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index a3a9c43f..5e45e7df 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -2,6 +2,8 @@ #include +#include + #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 diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index d98f0cb0..cbf2281e 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -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; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index f21468b1..4a519a22 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -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(menuItems.size()), metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), [&menuItems](int index) { return std::string(menuItems[index]); }, diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 5dc33e13..fde3fc83 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -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; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 7de7f960..1494361a 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -1,9 +1,11 @@ #include "UITheme.h" +#include #include #include #include +#include #include #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(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(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(); - currentMetrics = &BaseMetrics::values; + base = &BaseMetrics::values; break; case CrossPointSettings::UI_THEME::LYRA: LOG_DBG("UI", "Using Lyra theme"); currentTheme = std::make_unique(); - currentMetrics = &LyraMetrics::values; + base = &LyraMetrics::values; break; case CrossPointSettings::UI_THEME::ROUNDEDRAFF: LOG_DBG("UI", "Using RoundedRaff theme"); currentTheme = std::make_unique(); - 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(); - 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, diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 66602541..775d8d0f 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -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 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() diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 79f22948..a860cf0f 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -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& rowValue, bool highlightValue, const std::function& 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& 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& buttonLabel, const std::function& 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(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(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); } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 31bba4f0..aff7c73c 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -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); diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index fdbed623..d8a43639 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -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& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function 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(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); i++) { + i < std::min(static_cast(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(bitmap.getWidth()); float ratio = coverWidth / coverHeight; const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / - static_cast(Lyra3CoversMetrics::values.homeCoverHeight); + static_cast(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(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); + for (int i = 0; i < std::min(static_cast(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; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 6d5e8e03..f9661b2b 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -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& 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& rowValue, bool highlightValue, const std::function& 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& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function 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& buttonLabel, const std::function& 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; } } diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 0cfb3bb0..8607b9de 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -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, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index b3c8b0cb..b468a5fe 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -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& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function 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& buttonLabel, const std::function& 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; diff --git a/src/main.cpp b/src/main.cpp index 7a7b2bb3..f5403d7f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,7 @@ #include #include + +#include #include #include #include @@ -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;