From 06ce25f0cda4a9616507456d15031f601fa26b6f Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Sat, 27 Dec 2025 16:10:39 -0800 Subject: [PATCH] Grid Browser with file thumbnails --- lib/GfxRenderer/GfxRenderer.cpp | 140 ++++++++++ lib/GfxRenderer/GfxRenderer.h | 6 + src/CrossPointSettings.h | 3 + src/activities/home/GridBrowserActivity.cpp | 279 +++++++++++++++++++ src/activities/home/GridBrowserActivity.h | 60 ++++ src/activities/reader/ReaderActivity.cpp | 11 +- src/activities/settings/SettingsActivity.cpp | 1 + src/activities/util/Window.cpp | 71 +++++ src/activities/util/Window.h | 6 + src/images/FolderIcon.h | 58 ++++ 10 files changed, 633 insertions(+), 2 deletions(-) create mode 100644 src/activities/home/GridBrowserActivity.cpp create mode 100644 src/activities/home/GridBrowserActivity.h create mode 100644 src/activities/util/Window.cpp create mode 100644 src/activities/util/Window.h create mode 100644 src/images/FolderIcon.h diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index bcd8808..474511b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -80,6 +80,74 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +void GfxRenderer::drawTextInBox(const int fontId, const int x, const int y, const int w, const int h, const char* text, const bool centered, const bool black, const EpdFontStyle style) const { + const int lineHeight = getLineHeight(fontId); + const int spaceWidth = getSpaceWidth(fontId); + int xpos = x; + int ypos = y + lineHeight; + if (centered) { + int textWidth = getTextWidth(fontId, text, style); + if (textWidth < w) { + // Center if text on single line + xpos = x + (w - textWidth) / 2; + } + } + + // cannot draw a NULL / empty string + if (text == nullptr || *text == '\0') { + return; + } + + if (fontMap.count(fontId) == 0) { + Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + return; + } + const auto font = fontMap.at(fontId); + + // no printable characters + if (!font.hasPrintableChars(text, style)) { + return; + } + + uint32_t cp; + int ellipsisWidth = 0; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const int charWidth = getTextWidth(fontId, reinterpret_cast(&cp), style); + if (xpos + charWidth + ellipsisWidth > x + w) { + if (ellipsisWidth > 0) { + // Draw ellipsis and exit + int dotX = xpos; + renderChar(font, '.', &dotX, &ypos, black, style); + dotX += spaceWidth/3; + renderChar(font, '.', &dotX, &ypos, black, style); + dotX += spaceWidth/3; + renderChar(font, '.', &dotX, &ypos, black, style); + break; + } else { + // TODO center when more than one line + // if (centered) { + // int textWidth = getTextWidth(fontId, text, style); + // if (textWidth < w) { + // xpos = x + (w - textWidth) / 2; + // } + // } + xpos = x; + ypos += lineHeight; + if (h > 0 && ypos - y > h) { + // Overflowing box height + break; + } + if (h > 0 && ypos + lineHeight - y > h) { + // Last line, prepare ellipsis + ellipsisWidth = spaceWidth * 4; + } + } + } + + renderChar(font, cp, &xpos, &ypos, black, style); + } +} + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (x1 == x2) { if (y2 < y1) { @@ -101,6 +169,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); @@ -108,6 +182,68 @@ 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); + } + } +}; + +// 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); @@ -119,6 +255,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co einkDisplay.drawImage(bitmap, y, x, height, width); } +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 scale = 1.0f; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 00a525d..7c36533 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -45,15 +45,21 @@ 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, int lineWidth, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const; + void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const; + void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; void fillRect(int x, int y, int width, int height, bool state = true) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; + void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; // Text int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; + void drawTextInBox(int fontId, int x, int y, int w, int h, const char* text, bool centered, bool black = true, EpdFontStyle style = REGULAR) const; int getSpaceWidth(int fontId) const; int getLineHeight(int fontId) const; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index ab591be..c6f1b28 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -29,6 +29,9 @@ class CrossPointSettings { uint8_t extraParagraphSpacing = 1; // Duration of the power button press uint8_t shortPwrBtn = 0; + // UI Theme + enum UI_THEME { LIST = 0, GRID = 1 }; + uint8_t uiTheme = GRID; ~CrossPointSettings() = default; diff --git a/src/activities/home/GridBrowserActivity.cpp b/src/activities/home/GridBrowserActivity.cpp new file mode 100644 index 0000000..cd642f5 --- /dev/null +++ b/src/activities/home/GridBrowserActivity.cpp @@ -0,0 +1,279 @@ +#include "GridBrowserActivity.h" + +#include +#include +#include + +#include "config.h" +#include "../../images/FolderIcon.h" +#include "../util/Window.h" + +namespace { +constexpr int PAGE_ITEMS = 9; +constexpr int SKIP_PAGE_MS = 700; +constexpr int TILE_W = 135; +constexpr int TILE_H = 200; +constexpr int TILE_PADDING = 5; +constexpr int THUMB_W = 90; +constexpr int THUMB_H = 120; +constexpr int TILE_TEXT_H = 60; +constexpr int gridLeftOffset = 45; +constexpr int gridTopOffset = 125; +} // namespace + +inline int min(const int a, const int b) { return a < b ? a : b; } + +void GridBrowserActivity::sortFileList(std::vector& strs) { + std::sort(begin(strs), end(strs), [](const FileInfo& f1, const FileInfo& f2) { + if (f1.type == F_DIRECTORY && f2.type != F_DIRECTORY) return true; + if (f1.type != F_DIRECTORY && f2.type == F_DIRECTORY) return false; + return lexicographical_compare( + begin(f1.name), end(f1.name), begin(f2.name), end(f2.name), + [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); + }); +} + +void GridBrowserActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void GridBrowserActivity::loadFiles() { + files.clear(); + selectorIndex = 0; + previousSelectorIndex = -1; + page = 0; + auto root = SD.open(basepath.c_str()); + for (File file = root.openNextFile(); file; file = root.openNextFile()) { + const std::string filename = std::string(file.name()); + if (filename.empty() || filename[0] == '.') { + file.close(); + continue; + } + + if (file.isDirectory()) { + files.emplace_back(FileInfo{ filename, filename, F_DIRECTORY }); + } else { + FileType type = F_FILE; + size_t dot = filename.find_first_of('.'); + std::string basename = filename; + if (dot != std::string::npos) { + std::string ext = filename.substr(dot); + basename = filename.substr(0, dot); + // lowercase ext for case-insensitive compare + for (char &c : ext) c = (char)tolower(c); + if (ext == ".epub") { + type = F_EPUB; + } else if (ext == ".thumb.bmp") { + type = F_BMP; + } + } + if (type != F_FILE) { + files.emplace_back(FileInfo{ filename, basename, type }); + } + } + file.close(); + } + root.close(); + Serial.printf("Files loaded\n"); + GridBrowserActivity::sortFileList(files); + Serial.printf("Files sorted\n"); +} + +void GridBrowserActivity::onEnter() { + renderingMutex = xSemaphoreCreateMutex(); + + loadFiles(); + selectorIndex = 0; + page = 0; + + // Trigger first render + renderRequired = true; + + xTaskCreate(&GridBrowserActivity::taskTrampoline, "GridFileBrowserTask", + 8192, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void GridBrowserActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + files.clear(); +} + +void GridBrowserActivity::loop() { + const bool prevReleased = inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); + const bool nextReleased = inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); + const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; + const int selected = selectorIndex + page * PAGE_ITEMS; + + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (files.empty()) { + return; + } + + if (basepath.back() != '/') { + basepath += "/"; + } + if (files[selected].type == F_DIRECTORY) { + // open subfolder + basepath += files[selected].name; + loadFiles(); + renderRequired = true; + } else { + onSelect(basepath + files[selected].name); + } + } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + if (basepath != "/") { + basepath = basepath.substr(0, basepath.rfind('/')); + if (basepath.empty()) basepath = "/"; + loadFiles(); + renderRequired = true; + } else { + // At root level, go back home + onGoHome(); + } + } else if (prevReleased) { + previousSelectorIndex = selectorIndex; + if (selectorIndex == 0 || skipPage) { + if (page > 0) { + page--; + selectorIndex = 0; + previousSelectorIndex = -1; + renderRequired = true; + } + } else { + selectorIndex--; + updateRequired = true; + } + } else if (nextReleased) { + previousSelectorIndex = selectorIndex; + if (selectorIndex == min(PAGE_ITEMS, files.size() - page * PAGE_ITEMS) - 1 || skipPage) { + if (page < files.size() / PAGE_ITEMS) { + page++; + selectorIndex = 0; + previousSelectorIndex = -1; + renderRequired = true; + } + } else { + selectorIndex++; + updateRequired = true; + } + } +} + +void GridBrowserActivity::displayTaskLoop() { + while (true) { + if (renderRequired) { + renderRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(true); + xSemaphoreGive(renderingMutex); + } else if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + // update(true); + render(false); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void GridBrowserActivity::render(bool clear) const { + if (clear) { + renderer.clearScreen(); + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); + drawFullscreenWindowFrame(renderer, folderName); + } + bool hasGeyscaleBitmaps = false; + + if (!files.empty()) { + for (int pass = 0; pass < 3; pass++) { + if (pass > 0) { + renderer.clearScreen(0x00); + if (pass == 1) { + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + } else if (pass == 2) { + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + } + } + + for (size_t i = 0; i < min(PAGE_ITEMS, files.size() - page * PAGE_ITEMS); i++) { + const auto file = files[i + page * PAGE_ITEMS]; + + const int16_t tileX = gridLeftOffset + i % 3 * TILE_W; + const int16_t tileY = gridTopOffset + i / 3 * TILE_H; + + if (pass == 0) { + if (file.type == F_DIRECTORY) { + constexpr int iconOffsetX = (TILE_W - FOLDERICON_WIDTH) / 2; + constexpr int iconOffsetY = (TILE_H - TILE_TEXT_H - FOLDERICON_HEIGHT) / 2; + renderer.drawIcon(FolderIcon, tileX + iconOffsetX, tileY + iconOffsetY, FOLDERICON_WIDTH, FOLDERICON_HEIGHT); + } + } + + if (file.type == F_BMP) { + File bmpFile = SD.open((basepath + "/" + file.name).c_str()); + if (bmpFile) { + Bitmap bitmap(bmpFile); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + if (bitmap.hasGreyscale()) { + hasGeyscaleBitmaps = true; + } + constexpr int thumbOffsetX = (TILE_W - THUMB_W) / 2; + constexpr int thumbOffsetY = (TILE_H - TILE_TEXT_H - THUMB_H) / 2; + renderer.drawBitmap(bitmap, tileX + thumbOffsetX, tileY + thumbOffsetY, THUMB_W, THUMB_H); + } + } + } + + if (pass == 0) { + renderer.drawTextInBox(UI_FONT_ID, tileX + TILE_PADDING, tileY + TILE_H - TILE_TEXT_H, TILE_W - 2 * TILE_PADDING, TILE_TEXT_H, file.basename.c_str(), true); + } + } + + if (pass == 0) { + update(false); + renderer.displayBuffer(); + if (hasGeyscaleBitmaps) { + renderer.storeBwBuffer(); + } else { + // we can skip grayscale passes if no bitmaps use it + break; + } + } else if (pass == 1) { + renderer.copyGrayscaleLsbBuffers(); + } else if (pass == 2) { + renderer.copyGrayscaleMsbBuffers(); + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + renderer.restoreBwBuffer(); + } + } + } +} + +void GridBrowserActivity::drawSelectionRectangle(int tileIndex, bool black) const { + renderer.drawRoundedRect(gridLeftOffset + tileIndex % 3 * TILE_W, gridTopOffset + tileIndex / 3 * TILE_H, TILE_W, TILE_H, 2, 5, black); +} + +void GridBrowserActivity::update(bool render) const { + // Redraw only changed tiles + // renderer.clearScreen(); + if (previousSelectorIndex >= 0) { + drawSelectionRectangle(previousSelectorIndex, false); + } + drawSelectionRectangle(selectorIndex, true); +} \ No newline at end of file diff --git a/src/activities/home/GridBrowserActivity.h b/src/activities/home/GridBrowserActivity.h new file mode 100644 index 0000000..c3a0cd9 --- /dev/null +++ b/src/activities/home/GridBrowserActivity.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +enum FileType { + F_DIRECTORY = 0, + F_EPUB, + F_TXT, + F_BMP, + F_FILE +}; + +struct FileInfo { + std::string name; + std::string basename; + FileType type; +}; + +class GridBrowserActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + std::string basepath = "/"; + std::vector files; + int selectorIndex = 0; + int previousSelectorIndex = -1; + int page; + bool updateRequired = false; + bool renderRequired = false; + const std::function onSelect; + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(bool clear) const; + void update(bool render) const; + void loadFiles(); + void drawSelectionRectangle(int tileIndex, bool black) const; + + public: + explicit GridBrowserActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onSelect, + const std::function& onGoHome, + std::string initialPath = "/") + : Activity("FileSelection", renderer, inputManager), + onSelect(onSelect), + onGoHome(onGoHome), + basepath(initialPath.empty() ? "/" : std::move(initialPath)) {} + void onEnter() override; + void onExit() override; + void loop() override; + private: + static void sortFileList(std::vector& strs); +}; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 519a33a..54927bf 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -5,7 +5,9 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "../home/GridBrowserActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "../../CrossPointSettings.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); @@ -51,8 +53,13 @@ void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { exitActivity(); // If coming from a book, start in that book's folder; otherwise start from root const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); - enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + if (SETTINGS.uiTheme == CrossPointSettings::GRID) { + enterNewActivity(new GridBrowserActivity( + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + } else { + enterNewActivity(new FileSelectionActivity( + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + } } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b71a877..e37a847 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -16,6 +16,7 @@ const SettingInfo settingsList[settingsCount] = { {"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, + {"UI Theme", SettingType::ENUM, &CrossPointSettings::uiTheme, {"List", "Grid"}}, {"Check for updates", SettingType::ACTION, nullptr, {}}, }; } // namespace diff --git a/src/activities/util/Window.cpp b/src/activities/util/Window.cpp new file mode 100644 index 0000000..d3774d6 --- /dev/null +++ b/src/activities/util/Window.cpp @@ -0,0 +1,71 @@ +#include "./Window.h" +#include "Battery.h" +#include "config.h" + +namespace { +constexpr int windowCornerRadius = 16; +constexpr int windowBorderWidth = 2; +constexpr int fullscreenWindowMargin = 20; +constexpr int windowHeaderHeight = 50; +constexpr int statusBarHeight = 50; +constexpr int batteryWidth = 15; +constexpr int batteryHeight = 10; +} // namespace + +void drawWindowFrame(GfxRenderer& renderer, int xMargin, int y, int height, bool hasShadow, const char* title) { + const int windowWidth = GfxRenderer::getScreenWidth() - 2 * xMargin; + renderer.drawRoundedRect(xMargin, y, windowWidth, height, windowBorderWidth, windowCornerRadius, true); + + if (hasShadow) { + renderer.drawLine(windowWidth + xMargin, y + windowCornerRadius + 2, windowWidth + xMargin, y + height - windowCornerRadius, windowBorderWidth, true); + renderer.drawLine(xMargin + windowCornerRadius + 2, y + height, windowWidth + xMargin - windowCornerRadius, y + height, windowBorderWidth, true); + renderer.drawArc(windowCornerRadius + windowBorderWidth, windowWidth + xMargin - 1 - windowCornerRadius, y + height - 1 - windowCornerRadius, 1, 1, windowBorderWidth, true); + renderer.drawPixel(xMargin + windowCornerRadius + 1, y + height, true); + } + + if (title) { // Header + const int titleWidth = renderer.getTextWidth(UI_FONT_ID, title); + const int titleX = (GfxRenderer::getScreenWidth() - titleWidth) / 2; + const int titleY = y + 10; + renderer.drawText(UI_FONT_ID, titleX, titleY, title, true, REGULAR); + renderer.drawLine(xMargin, y + windowHeaderHeight, windowWidth + xMargin, y + windowHeaderHeight, windowBorderWidth, true); + } +} + +void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) { + drawStatusBar(renderer); + drawWindowFrame(renderer, fullscreenWindowMargin, statusBarHeight, GfxRenderer::getScreenHeight() - fullscreenWindowMargin - statusBarHeight, true, title); +} + +void drawStatusBar(GfxRenderer& renderer) { + constexpr auto textY = 18; + + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + const auto percentageTextWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, fullscreenWindowMargin + batteryWidth + 5, textY, percentageText.c_str()); + + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int x = fullscreenWindowMargin; + constexpr int y = textY + 5; + + // Top line + renderer.drawLine(x, y, x + batteryWidth - 4, y); + // Bottom line + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y, x, y + batteryHeight - 1); + // Battery end + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // 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 + 1, y + 1, filledWidth, batteryHeight - 2); +} \ No newline at end of file diff --git a/src/activities/util/Window.h b/src/activities/util/Window.h new file mode 100644 index 0000000..7315322 --- /dev/null +++ b/src/activities/util/Window.h @@ -0,0 +1,6 @@ +#pragma once +#include + +void drawWindowFrame(GfxRenderer& renderer, int xMargin, int y, int height, bool hasShadow, const char* title); +void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title); +void drawStatusBar(GfxRenderer& renderer); \ No newline at end of file diff --git a/src/images/FolderIcon.h b/src/images/FolderIcon.h new file mode 100644 index 0000000..a4e6ef0 --- /dev/null +++ b/src/images/FolderIcon.h @@ -0,0 +1,58 @@ +#pragma once +#include + +#define FOLDERICON_WIDTH 80 +#define FOLDERICON_HEIGHT 80 + +static const uint8_t FolderIcon[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, + 0xFF, 0xFF, 0xF8, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xF1, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xF3, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xCF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0x8F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xE3, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xCF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0x81, 0xFF, 0xFF, 0x8F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xE3, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x0F, 0xFF, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF +};