From 2d328b6a542dd384890a4241b5dfbeb2c4bb1d84 Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Mon, 19 Jan 2026 21:24:05 +0700 Subject: [PATCH] feat: Themed components --- lib/GfxRenderer/GfxRenderer.cpp | 125 ++++++++++++++ lib/GfxRenderer/GfxRenderer.h | 11 ++ open-x4-sdk | 2 +- src/CrossPointSettings.cpp | 5 +- src/CrossPointSettings.h | 5 + src/ScreenComponents.cpp | 66 -------- src/ScreenComponents.h | 24 --- .../browser/OpdsBookBrowserActivity.cpp | 4 +- src/activities/home/HomeActivity.cpp | 4 +- .../network/CalibreWirelessActivity.cpp | 4 +- src/activities/reader/EpubReaderActivity.cpp | 4 +- .../reader/FileSelectionActivity.cpp | 21 ++- src/activities/reader/TxtReaderActivity.cpp | 4 +- src/activities/settings/SettingsActivity.cpp | 56 +++--- src/components/UITheme.cpp | 70 ++++++++ src/components/UITheme.h | 35 ++++ src/components/themes/BaseTheme.cpp | 111 ++++++++++++ src/components/themes/BaseTheme.h | 31 ++++ .../themes/rounded/RoundedTheme.cpp | 160 ++++++++++++++++++ src/components/themes/rounded/RoundedTheme.h | 25 +++ src/main.cpp | 3 + 21 files changed, 626 insertions(+), 144 deletions(-) delete mode 100644 src/ScreenComponents.cpp delete mode 100644 src/ScreenComponents.h create mode 100644 src/components/UITheme.cpp create mode 100644 src/components/UITheme.h create mode 100644 src/components/themes/BaseTheme.cpp create mode 100644 src/components/themes/BaseTheme.h create mode 100644 src/components/themes/rounded/RoundedTheme.cpp create mode 100644 src/components/themes/rounded/RoundedTheme.h diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index e5b25bee..a1292452 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -131,6 +131,12 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con } } +void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x1, y1 + i, x2, y2 + i, state); + } +} + void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { drawLine(x, y, x + width - 1, y, state); drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); @@ -138,6 +144,121 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int drawLine(x, y, x, y + height - 1, state); } +// Border is inside the rectangle +void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth, + const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x + i, y + i, x + width - i, y + i, state); + drawLine(x + width - i, y + i, x + width - i, y + height - i, state); + drawLine(x + width - i, y + height - i, x + i, y + height - i, state); + drawLine(x + i, y + height - i, x + i, y + i, state); + } +} + +void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, + const int lineWidth, const bool state) const { + const int stroke = std::min(lineWidth, maxRadius); + const int innerRadius = std::max(maxRadius - stroke, 0); + const int outerRadiusSq = maxRadius * maxRadius; + const int innerRadiusSq = innerRadius * innerRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + if (distSq > outerRadiusSq || distSq < innerRadiusSq) { + continue; + } + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + drawPixel(px, py, state); + } + } +}; + +// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level - 0 white to 15 black +void GfxRenderer::fillRectGrey(const int x, const int y, const int width, const int height, const int greyLevel) const { + static constexpr uint8_t bayer4x4[4][4] = { + {0, 8, 2, 10}, + {12, 4, 14, 6}, + {3, 11, 1, 9}, + {15, 7, 13, 5}, + }; + static constexpr int matrixSize = 4; + static constexpr int matrixLevels = matrixSize * matrixSize; + + const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1); + const int clampedGrey = std::max(0, std::min(normalizedGrey, 255)); + const int threshold = (clampedGrey * (matrixLevels + 1)) / 256; + + for (int dy = 0; dy < height; ++dy) { + const int screenY = y + dy; + const int matrixY = screenY & (matrixSize - 1); + for (int dx = 0; dx < width; ++dx) { + const int screenX = x + dx; + const int matrixX = screenX & (matrixSize - 1); + const uint8_t patternValue = bayer4x4[matrixY][matrixX]; + const bool black = patternValue < threshold; + drawPixel(screenX, screenY, black); + } + } +} + +// Color -1 white, 0 clear, 1 black +void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, + const int insideColor, const int outsideColor) const { + const int radiusSq = maxRadius * maxRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + if (distSq > radiusSq) { + if (outsideColor != 0) { + drawPixel(px, py, outsideColor == 1); + } + } else { + if (insideColor != 0) { + drawPixel(px, py, insideColor == 1); + } + } + } + } +}; + +// Border is inside the rectangle, rounded corners +void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, + const int cornerRadius, const bool state) const { + if (lineWidth <= 0 || width <= 0 || height <= 0) { + return; + } + + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); + if (maxRadius <= 0) { + drawRect(x, y, width, height, lineWidth, state); + return; + } + + const int stroke = std::min(lineWidth, maxRadius); + const int right = x + width - 1; + const int bottom = y + height - 1; + + const int horizontalWidth = width - 2 * maxRadius; + if (horizontalWidth > 0) { + fillRect(x + maxRadius, y, horizontalWidth, stroke, state); + fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state); + } + + const int verticalHeight = height - 2 * maxRadius; + if (verticalHeight > 0) { + fillRect(x, y + maxRadius, stroke, verticalHeight, state); + fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state); + } + + drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state); // TL + drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state); // TR + drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state); // BR + drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state); // BL +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { for (int fillY = y; fillY < y + height; fillY++) { drawLine(x, fillY, x + width - 1, fillY, state); @@ -152,6 +273,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height); } +void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { + einkDisplay.drawImage(bitmap, y, getScreenWidth() - width - x, height, width); +} + void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b1fea69b..9d6b0f3a 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -63,9 +63,20 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; + void drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const; + void drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, const int lineWidth, + const bool state) const; void drawRect(int x, int y, int width, int height, bool state = true) const; + void drawRect(const int x, const int y, const int width, const int height, const int lineWidth, + const bool state) const; + void drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, + const int cornerRadius, const bool state) const; void fillRect(int x, int y, int width, int height, bool state = true) const; + void fillRectGrey(const int x, const int y, const int width, const int height, const int greyLevel) const; + void fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, const int insideColor, + const int outsideColor) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; + void drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; diff --git a/open-x4-sdk b/open-x4-sdk index bd4e6707..fe766f15 160000 --- a/open-x4-sdk +++ b/open-x4-sdk @@ -1 +1 @@ -Subproject commit bd4e6707503ab9c97d13ee0d8f8c69e9ff03cd12 +Subproject commit fe766f15cb9ff1ef8214210a8667037df4c60b81 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index f5e8ded5..44f908c4 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 20; +constexpr uint8_t SETTINGS_COUNT = 21; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -49,6 +49,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, hideBatteryPercentage); serialization::writePod(outputFile, longPressChapterSkip); serialization::writePod(outputFile, hyphenationEnabled); + serialization::writePod(outputFile, uiTheme); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -120,6 +121,8 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, hyphenationEnabled); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, uiTheme); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 8ce32a2c..e0ade7ee 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -58,6 +58,9 @@ class CrossPointSettings { // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + // UI Theme + enum UI_THEME { CLASSIC = 0, ROUNDED = 1 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -94,6 +97,8 @@ class CrossPointSettings { uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // UI Theme + uint8_t uiTheme = CLASSIC; ~CrossPointSettings() = default; diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp deleted file mode 100644 index 42b6ef7b..00000000 --- a/src/ScreenComponents.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "ScreenComponents.h" - -#include - -#include -#include - -#include "Battery.h" -#include "fontIds.h" - -void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, - const bool showPercentage) { - // Left aligned battery icon and percentage - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; - renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); - - // 1 column on left, 2 columns on right, 5 columns of battery body - constexpr int batteryWidth = 15; - constexpr int batteryHeight = 12; - const int x = left; - const int y = top + 6; - - // Top line - renderer.drawLine(x + 1, y, x + batteryWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); - // Battery end - renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); - renderer.drawPixel(x + batteryWidth - 1, y + 3); - renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); - renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); - - // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; - if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow - } - - renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); -} - -void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width, - const int height, const size_t current, const size_t total) { - if (total == 0) { - return; - } - - // Use 64-bit arithmetic to avoid overflow for large files - const int percent = static_cast((static_cast(current) * 100) / total); - - // Draw outline - renderer.drawRect(x, y, width, height); - - // Draw filled portion - const int fillWidth = (width - 4) * percent / 100; - if (fillWidth > 0) { - renderer.fillRect(x + 2, y + 2, fillWidth, height - 4); - } - - // Draw percentage text centered below bar - const std::string percentText = std::to_string(percent) + "%"; - renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); -} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h deleted file mode 100644 index 150fb0c8..00000000 --- a/src/ScreenComponents.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -class GfxRenderer; - -class ScreenComponents { - public: - static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); - - /** - * Draw a progress bar with percentage text. - * @param renderer The graphics renderer - * @param x Left position of the bar - * @param y Top position of the bar - * @param width Width of the bar - * @param height Height of the bar - * @param current Current progress value - * @param total Total value for 100% progress - */ - static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, - size_t total); -}; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 4e0a08d2..9b1a68c2 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -6,8 +6,8 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" #include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/StringUtils.h" @@ -205,7 +205,7 @@ void OpdsBookBrowserActivity::render() const { constexpr int barHeight = 20; constexpr int barX = 50; const int barY = pageHeight / 2 + 20; - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); + UITheme::drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); } renderer.displayBuffer(); return; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 6f27e39c..9f9323f8 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -13,7 +13,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" #include "util/StringUtils.h" @@ -550,7 +550,7 @@ void HomeActivity::render() { const uint16_t percentage = battery.readPercentage(); const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); + UITheme::drawBattery(renderer, Rect{batteryX, 10}, showBatteryPercentage); renderer.displayBuffer(); } diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp index 0ad9094a..4570d40a 100644 --- a/src/activities/network/CalibreWirelessActivity.cpp +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -8,7 +8,7 @@ #include #include "MappedInputManager.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" #include "util/StringUtils.h" @@ -711,7 +711,7 @@ void CalibreWirelessActivity::render() const { constexpr int barHeight = 20; constexpr int barX = 50; const int barY = statusY + 20; - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize); + UITheme::drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, bytesReceived, currentFileSize); } // Draw error if present diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index d70a15c4..1ef223ab 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -9,7 +9,7 @@ #include "CrossPointState.h" #include "EpubReaderChapterSelectionActivity.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -455,7 +455,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); + UITheme::drawBattery(renderer, Rect{orientedMarginLeft + 1, textY}, showBatteryPercentage); } if (showChapterTitle) { diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index 3ef42c1c..40f95e93 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -4,6 +4,7 @@ #include #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" #include "util/StringUtils.h" @@ -180,23 +181,21 @@ void FileSelectionActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Books", true, EpdFontFamily::BOLD); + + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); + UITheme::drawFullscreenWindowFrame(renderer, folderName); + Rect contentRect = UITheme::getWindowContentFrame(renderer); // Help text const auto labels = mappedInput.mapLabels("« Home", "Open", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (files.empty()) { - renderer.drawText(UI_10_FONT_ID, 20, 60, "No books found"); - renderer.displayBuffer(); - return; - } - - const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; - renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - for (size_t i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { - auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), renderer.getScreenWidth() - 40); - renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); + renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, contentRect.y + 20, "No books found"); + } else { + UITheme::drawList( + renderer, contentRect, files.size(), selectorIndex, [this](int index) { return files[index]; }, false, nullptr, + false, nullptr); } renderer.displayBuffer(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index db725320..810819b4 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -8,7 +8,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -517,7 +517,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + UITheme::drawBattery(renderer, Rect{orientedMarginLeft, textY}); } if (showTitle) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7907e50f..8c6c9954 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,11 +10,12 @@ #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "components/UITheme.h" #include "fontIds.h" // Define the static settings list namespace { -constexpr int settingsCount = 22; +constexpr int settingsCount = 23; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -27,7 +28,7 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, - {"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght"}), + {"Bck, OK, L, R", "L, R, Bck, OK", "L, Bck, OK, R"}), SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout, {"Prev, Next", "Next, Prev"}), SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip), @@ -43,6 +44,7 @@ const SettingInfo settingsList[settingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Rounded"}), SettingInfo::Action("KOReader Sync"), SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; @@ -82,6 +84,8 @@ void SettingsActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + UITheme::initialize(); // Re-apply theme in case it was changed } void SettingsActivity::loop() { @@ -193,36 +197,26 @@ void SettingsActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD); + UITheme::drawFullscreenWindowFrame(renderer, "Settings"); + Rect contentRect = UITheme::getWindowContentFrame(renderer); - // Draw selection highlight - renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); - - // Draw all settings - for (int i = 0; i < settingsCount; i++) { - const int settingY = 60 + i * 30; // 30 pixels between settings - const bool isSelected = (i == selectedSettingIndex); - - // Draw setting name - renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected); - - // Draw value based on setting type - std::string valueText; - if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - const bool value = SETTINGS.*(settingsList[i].valuePtr); - valueText = value ? "ON" : "OFF"; - } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { - const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); - valueText = settingsList[i].enumValues[value]; - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); - } - if (!valueText.empty()) { - const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected); - } - } + UITheme::drawList( + renderer, contentRect, settingsCount, selectedSettingIndex, + [](int index) { return std::string(settingsList[index].name); }, false, nullptr, true, + [this](int i) { + const auto& setting = settingsList[i]; + std::string valueText = ""; + if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { + const bool value = SETTINGS.*(settingsList[i].valuePtr); + valueText = value ? "ON" : "OFF"; + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); + valueText = settingsList[i].enumValues[value]; + } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { + valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } + return valueText; + }); // Draw version text above button hints renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp new file mode 100644 index 00000000..36fd1d47 --- /dev/null +++ b/src/components/UITheme.cpp @@ -0,0 +1,70 @@ +#include "UITheme.h" + +#include + +#include + +#include "components/themes/BaseTheme.h" +#include "components/themes/rounded/RoundedTheme.h" + +std::unique_ptr currentTheme = nullptr; + +// Initialize theme based on settings +void UITheme::initialize() { + auto themeType = static_cast(SETTINGS.uiTheme); + setTheme(themeType); +} + +void UITheme::setTheme(CrossPointSettings::UI_THEME type) { + switch (type) { + case CrossPointSettings::UI_THEME::CLASSIC: + Serial.printf("[%lu] [UI] Using Classic theme\n", millis()); + currentTheme = std::unique_ptr(new BaseTheme()); + break; + case CrossPointSettings::UI_THEME::ROUNDED: + Serial.printf("[%lu] [UI] Using Rounded theme\n", millis()); + currentTheme = std::unique_ptr(new RoundedTheme()); + break; + } +} + +// Forward all component methods to the current theme +void UITheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) { + if (currentTheme != nullptr) { + currentTheme->drawProgressBar(renderer, rect, current, total); + } +} + +void UITheme::drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage) { + if (currentTheme != nullptr) { + currentTheme->drawBattery(renderer, rect, showPercentage); + } +} + +void UITheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + if (currentTheme != nullptr) { + currentTheme->drawList(renderer, rect, itemCount, selectedIndex, rowTitle, hasIcon, rowIcon, hasValue, rowValue); + } +} + +void UITheme::drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title) { + if (currentTheme != nullptr) { + currentTheme->drawWindowFrame(renderer, rect, isPopup, title); + } +} + +void UITheme::drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) { + if (currentTheme != nullptr) { + currentTheme->drawFullscreenWindowFrame(renderer, title); + } +} + +Rect UITheme::getWindowContentFrame(GfxRenderer& renderer) { + if (currentTheme != nullptr) { + return currentTheme->getWindowContentFrame(renderer); + } + return Rect{}; +} diff --git a/src/components/UITheme.h b/src/components/UITheme.h new file mode 100644 index 00000000..81094363 --- /dev/null +++ b/src/components/UITheme.h @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include "CrossPointSettings.h" + +class GfxRenderer; + +struct Rect { + int x; + int y; + int width; + int height; + + // Constructor for explicit initialization + explicit Rect(int x = 0, int y = 0, int width = 0, int height = 0) : x(x), y(y), width(width), height(height) {} +}; + +class UITheme { + public: + static void initialize(); + static void setTheme(CrossPointSettings::UI_THEME type); + + static Rect getWindowContentFrame(GfxRenderer& renderer); + + static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); + static void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true); + static void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue); + + static void drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title); + static void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title); +}; \ No newline at end of file diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp new file mode 100644 index 00000000..1c5534be --- /dev/null +++ b/src/components/themes/BaseTheme.cpp @@ -0,0 +1,111 @@ +#include "BaseTheme.h" + +#include + +#include +#include + +#include "Battery.h" +#include "fontIds.h" + +namespace { +constexpr int rowHeight = 30; +constexpr int pageItems = 23; +} // namespace + +void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; + renderer.drawText(SMALL_FONT_ID, rect.x + 20, rect.y, percentageText.c_str()); + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 12; + const int x = rect.x; + const int y = rect.y + 6; + + // Top line + renderer.drawLine(x + 1, y, x + batteryWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); + // Battery end + renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); + renderer.drawPixel(x + batteryWidth - 1, y + 3); + renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); + renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; // Ensure we don't overflow + } + + renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); +} + +void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, const size_t total) { + if (total == 0) { + return; + } + + // Use 64-bit arithmetic to avoid overflow for large files + const int percent = static_cast((static_cast(current) * 100) / total); + + // Draw outline + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); + + // Draw filled portion + const int fillWidth = (rect.width - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4); + } + + // Draw percentage text centered below bar + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str()); +} + +void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + // Draw selection + renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width - 1, rowHeight); + // Draw all items + const auto pageStartIndex = selectedIndex / pageItems * pageItems; + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int itemY = rect.y + (i % pageItems) * rowHeight; + + // Draw name + auto itemName = rowTitle(i); + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), + rect.width - (hasValue ? 100 : 40)); // TODO truncate according to value width? + renderer.drawText(UI_10_FONT_ID, 20, itemY, item.c_str(), i != selectedIndex); + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + renderer.drawText(UI_10_FONT_ID, rect.width - 20 - textWidth, itemY, valueText.c_str(), i != selectedIndex); + } + } +} + +Rect BaseTheme::getWindowContentFrame(GfxRenderer& renderer) { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + return Rect{0, 60, pageWidth, pageHeight - 120}; +} + +void BaseTheme::drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title) { + if (title) { + renderer.drawCenteredText(UI_12_FONT_ID, rect.y, title, true, EpdFontFamily::BOLD); + } +} + +void BaseTheme::drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) { + BaseTheme::drawWindowFrame(renderer, Rect{0, 15, renderer.getScreenWidth(), renderer.getScreenHeight()}, false, + title); +} diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h new file mode 100644 index 00000000..e2d2b84c --- /dev/null +++ b/src/components/themes/BaseTheme.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "components/UITheme.h" + +class GfxRenderer; + +// Default theme implementation (Classic Theme) +// Additional themes can inherit from this and override methods as needed + +class BaseTheme { + public: + virtual ~BaseTheme() = default; + + // Property getters + virtual Rect getWindowContentFrame(GfxRenderer& renderer); + + // Component drawing methods + virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); + virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true); + virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue); + + virtual void drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title); + virtual void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title); +}; \ No newline at end of file diff --git a/src/components/themes/rounded/RoundedTheme.cpp b/src/components/themes/rounded/RoundedTheme.cpp new file mode 100644 index 00000000..43c6d7b5 --- /dev/null +++ b/src/components/themes/rounded/RoundedTheme.cpp @@ -0,0 +1,160 @@ +#include "RoundedTheme.h" + +#include + +#include +#include + +#include "Battery.h" +#include "fontIds.h" + +namespace { +constexpr int rowHeight = 64; +constexpr int pageItems = 9; +constexpr int windowCornerRadius = 16; +constexpr int windowBorderWidth = 2; +constexpr int fullscreenWindowMargin = 20; +constexpr int windowHeaderHeight = 50; +constexpr int statusBarHeight = 50; +constexpr int buttonHintsHeight = 50; +} // namespace + +void RoundedTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; + renderer.drawText(SMALL_FONT_ID, rect.x + 20, rect.y, percentageText.c_str()); + + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 12; + const int x = rect.x; + const int y = rect.y + 6; + + // Top line + renderer.drawLine(x + 1, y, x + batteryWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); + // Battery end + renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); + renderer.drawPixel(x + batteryWidth - 1, y + 3); + renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); + renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); + + // Draw bars + if (percentage > 10) { + renderer.fillRect(x + 2, y + 2, 3, batteryHeight - 4); + } + if (percentage > 40) { + renderer.fillRect(x + 6, y + 2, 3, batteryHeight - 4); + } + if (percentage > 70) { + renderer.fillRect(x + 10, y + 2, 2, batteryHeight - 4); + } +} + +void RoundedTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, const size_t total) { + if (total == 0) { + return; + } + + // Use 64-bit arithmetic to avoid overflow for large files + const int percent = static_cast((static_cast(current) * 100) / total); + + // Draw filled portion + const int fillWidth = (rect.width - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4); + } + + // Draw percentage text centered below bar + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str()); +} + +void RoundedTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + const int totalPages = (itemCount + pageItems - 1) / pageItems; + if (totalPages > 1) { + // Draw scroll bar + const int scrollBarHeight = (rect.height * pageItems) / itemCount; + const int currentPage = selectedIndex / pageItems; + const int scrollBarY = rect.y + ((rect.height - scrollBarHeight) * currentPage) / (totalPages - 1); + renderer.fillRectGrey(rect.x + rect.width, rect.y, 4, rect.height, 5); + renderer.fillRect(rect.x + rect.width, scrollBarY, 4, scrollBarHeight, true); + } + + // Draw selection + renderer.fillRectGrey(rect.x, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width - 1, rowHeight, 3); + // Draw all items + const auto pageStartIndex = selectedIndex / pageItems * pageItems; + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int itemY = rect.y + (i % pageItems) * rowHeight; + + // Draw name + auto itemName = rowTitle(i); + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), + rect.width - (hasValue ? 100 : 40)); // TODO truncate according to value width? + renderer.drawText(UI_10_FONT_ID, rect.x + 20, itemY + 20, item.c_str(), true); + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + renderer.drawText(UI_10_FONT_ID, rect.x + rect.width - 20 - textWidth, itemY + 20, valueText.c_str(), true); + } + } +} + +Rect RoundedTheme::getWindowContentFrame(GfxRenderer& renderer) { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + return Rect{35, 125, pageWidth - 70, pageHeight - 165 - buttonHintsHeight}; +} + +void RoundedTheme::drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title) { + const int windowWidth = renderer.getScreenWidth() - 2 * rect.x; + + if (title) { // Header background + renderer.fillRectGrey(rect.x, rect.y, windowWidth, windowHeaderHeight, 5); + renderer.fillArc(windowCornerRadius, rect.x + windowCornerRadius, rect.y + windowCornerRadius, -1, -1, 0, + -1); // TL + renderer.fillArc(windowCornerRadius, windowWidth + rect.x - windowCornerRadius, rect.y + windowCornerRadius, 1, -1, + 0, -1); // TR + } + + renderer.drawRoundedRect(rect.x, rect.y, windowWidth, rect.height, windowBorderWidth, windowCornerRadius, true); + + if (!isPopup) { + renderer.drawLine(windowWidth + rect.x, rect.y + windowCornerRadius + 2, windowWidth + rect.x, + rect.y + rect.height - windowCornerRadius, windowBorderWidth, true); + renderer.drawLine(rect.x + windowCornerRadius + 2, rect.y + rect.height, windowWidth + rect.x - windowCornerRadius, + rect.y + rect.height, windowBorderWidth, true); + renderer.drawArc(windowCornerRadius + windowBorderWidth, windowWidth + rect.x - 1 - windowCornerRadius, + rect.y + rect.height - 1 - windowCornerRadius, 1, 1, windowBorderWidth, true); + renderer.drawPixel(rect.x + windowCornerRadius + 1, rect.y + rect.height, true); + } + + if (title) { // Header + const int titleWidth = renderer.getTextWidth(UI_12_FONT_ID, title); + const int titleX = (renderer.getScreenWidth() - titleWidth) / 2; + const int titleY = rect.y + 10; + renderer.drawText(UI_12_FONT_ID, titleX, titleY, title, true, EpdFontFamily::REGULAR); + renderer.drawLine(rect.x, rect.y + windowHeaderHeight, windowWidth + rect.x, rect.y + windowHeaderHeight, + windowBorderWidth, true); + } +} + +void RoundedTheme::drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) { + // drawStatusBar(renderer); + RoundedTheme::drawWindowFrame( + renderer, + Rect{fullscreenWindowMargin, statusBarHeight, 0, + renderer.getScreenHeight() - fullscreenWindowMargin - statusBarHeight - buttonHintsHeight}, + false, title); + RoundedTheme::drawBattery(renderer, Rect{fullscreenWindowMargin, 18}, false); +} diff --git a/src/components/themes/rounded/RoundedTheme.h b/src/components/themes/rounded/RoundedTheme.h new file mode 100644 index 00000000..637d650c --- /dev/null +++ b/src/components/themes/rounded/RoundedTheme.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include "components/themes/BaseTheme.h" + +class GfxRenderer; + +class RoundedTheme : public BaseTheme { + public: + // Property getters + Rect getWindowContentFrame(GfxRenderer& renderer) override; + + // Component drawing methods + void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; + void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) override; + + void drawWindowFrame(GfxRenderer& renderer, Rect rect, bool isPopup, const char* title) override; + void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) override; +}; diff --git a/src/main.cpp b/src/main.cpp index e0ad316a..bc05f108 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,6 +22,7 @@ #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "components/UITheme.h" #include "fontIds.h" #define SPI_FQ 40000000 @@ -273,6 +274,7 @@ void setup() { } inputManager.begin(); + // Initialize pins pinMode(BAT_GPIO0, INPUT); @@ -291,6 +293,7 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); + UITheme::initialize(); // verify power button press duration after we've read settings. verifyWakeupLongPress();