From 442b666826b2a51da00c74a07a2669d9d774b8f2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Fri, 20 Mar 2026 04:33:29 +0100 Subject: [PATCH 1/4] feat: battery charging indicator (mirroring PR #537) (#1427) ## Summary * **What is the goal of this PR?** All praise goes to @didacta for his PR #537. Just picked up the reviewer comments to contain the changes as suggested (there was no response for more than 6 weeks, so I wanted to reanimate this feature). Just one addition: should recognize usb cable plug ins / retractions and update the icon immediately * **What changes are included?** ## Additional Context see #537 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< NO >**_ --- lib/hal/HalGPIO.cpp | 9 ++- lib/hal/HalGPIO.h | 8 ++ src/components/themes/BaseTheme.cpp | 36 ++++++++- src/components/themes/lyra/LyraTheme.cpp | 99 +++++++++++------------- src/main.cpp | 6 ++ 5 files changed, 100 insertions(+), 58 deletions(-) diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 64a251de..ec0176a2 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -7,7 +7,14 @@ void HalGPIO::begin() { pinMode(UART0_RXD, INPUT); } -void HalGPIO::update() { inputMgr.update(); } +void HalGPIO::update() { + inputMgr.update(); + const bool connected = isUsbConnected(); + usbStateChanged = (connected != lastUsbConnected); + lastUsbConnected = connected; +} + +bool HalGPIO::wasUsbStateChanged() const { return usbStateChanged; } bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); } diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 45ca50a5..a283ed60 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -23,6 +23,9 @@ class HalGPIO { InputManager inputMgr; #endif + bool lastUsbConnected = false; + bool usbStateChanged = false; + public: HalGPIO() = default; @@ -41,6 +44,9 @@ class HalGPIO { // Check if USB is connected bool isUsbConnected() const; + // Returns true once per edge (plug or unplug) since the last update() + bool wasUsbStateChanged() const; + enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other }; WakeupReason getWakeupReason() const; @@ -54,3 +60,5 @@ class HalGPIO { static constexpr uint8_t BTN_DOWN = 5; static constexpr uint8_t BTN_POWER = 6; }; + +extern HalGPIO gpio; // Singleton diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 53d82a99..9c563eb1 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -34,13 +35,40 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + const bool charging = gpio.isUsbConnected(); + // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (battWidth - 5) / 100 + 1; - if (filledWidth > battWidth - 5) { - filledWidth = battWidth - 5; // Ensure we don't overflow + const int maxFillWidth = battWidth - 5; + const int fillHeight = rectHeight - 4; + if (maxFillWidth <= 0 || fillHeight <= 0) { + return; + } + int filledWidth = percentage * maxFillWidth / 100 + 1; + if (filledWidth > maxFillWidth) { + filledWidth = maxFillWidth; } - renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4); + // When charging, ensure minimum fill so lightning bolt is fully visible + constexpr int minFillForBolt = 8; + if (charging && filledWidth < minFillForBolt) { + filledWidth = std::min(minFillForBolt, maxFillWidth); + } + + renderer.fillRect(x + 2, y + 2, filledWidth, fillHeight); + + // Draw lightning bolt when charging (white/inverted on black fill for visibility) + if (charging) { + const int boltX = x + 4; + const int boltY = y + 2; + renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); + renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); + renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); + renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); + renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); + renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); + renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); + renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); + } } } // namespace diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 36c19501..58dabeab 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -1,6 +1,7 @@ #include "LyraTheme.h" #include +#include #include #include #include @@ -42,6 +43,47 @@ constexpr int listIconSize = 24; constexpr int mainMenuColumns = 2; int coverWidth = 0; +void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, + uint16_t percentage) { + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rectHeight - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + + const bool charging = gpio.isUsbConnected(); + + // Draw bars + if (percentage > 10 || charging) { + renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4); + } + if (percentage > 40 || charging) { + renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4); + } + if (percentage > 70) { + renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4); + } + + if (charging) { + const int boltX = x + 4; + const int boltY = y + 2; + renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); + renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); + renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); + renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); + renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); + renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); + renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); + renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); + } +} + const uint8_t* iconForName(UIIcon icon, int size) { if (size == 24) { switch (icon) { @@ -87,45 +129,19 @@ const uint8_t* iconForName(UIIcon icon, int size) { void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Left aligned: icon on left, percentage on right (reader mode) const uint16_t percentage = powerManager.getBatteryPercentage(); - const int y = rect.y + 6; - const int battWidth = LyraMetrics::values.batteryWidth; if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; - renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + battWidth, rect.y, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y, + percentageText.c_str()); } - // Draw icon - const int x = rect.x; - // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + rect.height - 2); - // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); - - // Draw bars - if (percentage > 10) { - renderer.fillRect(x + 2, y + 2, 3, rect.height - 4); - } - if (percentage > 40) { - renderer.fillRect(x + 6, y + 2, 3, rect.height - 4); - } - if (percentage > 70) { - renderer.fillRect(x + 10, y + 2, 3, rect.height - 4); - } + drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); } void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Right aligned: percentage on left, icon on right (UI headers) const uint16_t percentage = powerManager.getBatteryPercentage(); - const int y = rect.y + 6; - const int battWidth = LyraMetrics::values.batteryWidth; if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; @@ -137,30 +153,7 @@ void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); } - // Draw icon at rect.x - const int x = rect.x; - // Top line - renderer.drawLine(x + 1, y, x + battWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + rect.height - 2); - // Battery end - renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); - renderer.drawPixel(x + battWidth - 1, y + 3); - renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); - renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); - - // Draw bars - if (percentage > 10) { - renderer.fillRect(x + 2, y + 2, 3, rect.height - 4); - } - if (percentage > 40) { - renderer.fillRect(x + 6, y + 2, 3, rect.height - 4); - } - if (percentage > 70) { - renderer.fillRect(x + 10, y + 2, 3, rect.height - 4); - } + drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); } void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { diff --git a/src/main.cpp b/src/main.cpp index 7bd21f37..75bf69c5 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -379,6 +379,12 @@ void loop() { return; } + // Refresh the battery icon when USB is plugged or unplugged. + // Placed after sleep guards so we never queue a render that won't be processed. + if (gpio.wasUsbStateChanged()) { + activityManager.requestUpdate(); + } + const unsigned long activityStartTime = millis(); activityManager.loop(); const unsigned long activityDuration = millis() - activityStartTime; From 2af434cecda6865fc6489541c41fdf9c663099cc Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 19 Mar 2026 23:19:54 -0500 Subject: [PATCH 2/4] chore: Update SKILL.md to reflect generated i18n files are gitignored (#1423) ## Summary **What is the goal of this PR?** Update SKILL.md to stop instructing contributors to commit `I18nKeys.h` and `I18nStrings.h`. All three generated i18n files have been gitignored since 1b7dde07 and are regenerated at build time. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- .skills/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.skills/SKILL.md b/.skills/SKILL.md index 30cd9de3..23e7772a 100644 --- a/.skills/SKILL.md +++ b/.skills/SKILL.md @@ -664,7 +664,7 @@ Tested in all 4 orientations with 5MB+ files. - `lib/I18n/I18nKeys.h`, `lib/I18n/I18nStrings.h`, `lib/I18n/I18nStrings.cpp` - **Source**: YAML translation files in `lib/I18n/translations/` (one per language) - **To modify**: Edit source YAML files, then run `python scripts/gen_i18n.py lib/I18n/translations lib/I18n/` - - **Commit**: Source YAML files + `I18nKeys.h` and `I18nStrings.h` (needed for IDE symbol resolution), but NOT `I18nStrings.cpp` + - **Commit**: Source YAML files only. All three generated files (`I18nKeys.h`, `I18nStrings.h`, `I18nStrings.cpp`) are in `.gitignore` and regenerated at build time. 3. **Build Artifacts** (in `.gitignore`): - `.pio/` - PlatformIO build output @@ -686,7 +686,7 @@ Tested in all 4 orientations with 5MB+ files. - English (`english.yaml`) is the reference; missing keys in other languages fall back to English 2. Run generator: `python scripts/gen_i18n.py lib/I18n/translations lib/I18n/` 3. Generated files update: `I18nKeys.h`, `I18nStrings.h`, `I18nStrings.cpp` -4. **Commit** source YAML files + `I18nKeys.h` and `I18nStrings.h` (IDE needs these for symbol resolution), but NOT `I18nStrings.cpp` +4. **Commit** source YAML files only. All three generated files are in `.gitignore` and regenerated at build time. **To use translated strings in code**: ```cpp From 2ab4e30b9ad2288f8aa9b9e9582274cef2ffb383 Mon Sep 17 00:00:00 2001 From: LSTAR <48829261+LSTAR1900@users.noreply.github.com> Date: Fri, 20 Mar 2026 04:20:26 +0000 Subject: [PATCH 3/4] feat: Implement silent pre-indexing for the next chapter in EpubReaderActivity (#979) ## Summary * A simple tweak to pre-index the next chapter silently during normal reading. * Triggers silent pre-indexing of the next chapter when the penultimate page of a chapter is rendered to reduce visible interruptions. * Keeps existing indexing with popup when a reader jumps directly into an unindexed chapter. ## Additional Context * Reader input is temporarily blocked during silent indexing to avoid navigation/index state conflicts. * The penultimate page is used because readers typically spend longer there than on the final page. * This change optimizes linear reading flow while preserving reliable indexing for non-linear navigation. ## Possible Improvements * Add a setting for First Page Indexing vs Penultimate Page Pre-indexing * Display an indexing icon in the status bar instead of using a popup that overlaps book text. Tested on device: https://www.dropbox.com/scl/fi/29g5kjqgsi5e4hgujv38u/Silent-Indexing.MOV?rlkey=yemi4mosmev5vicaa7gpe49qw&dl=0 --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES**_ --------- Co-authored-by: Jake Kenneally --- src/activities/reader/EpubReaderActivity.cpp | 39 ++++++++++++++++++-- src/activities/reader/EpubReaderActivity.h | 1 + 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ca455a11..0ce1c5b1 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -530,14 +530,14 @@ void EpubReaderActivity::render(RenderLock&& lock) { orientedMarginBottom += std::max(SETTINGS.screenMargin, statusBarHeight); } + const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; + const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; + if (!section) { const auto filepath = epub->getSpineItem(currentSpineIndex).href; LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); - const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; - const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; - if (!section->loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, @@ -635,6 +635,7 @@ void EpubReaderActivity::render(RenderLock&& lock) { renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); } + silentIndexNextChapterIfNeeded(viewportWidth, viewportHeight); saveProgress(currentSpineIndex, section->currentPage, section->pageCount); if (pendingScreenshot) { @@ -643,6 +644,38 @@ void EpubReaderActivity::render(RenderLock&& lock) { } } +void EpubReaderActivity::silentIndexNextChapterIfNeeded(const uint16_t viewportWidth, const uint16_t viewportHeight) { + if (!epub || !section || section->pageCount < 2) { + return; + } + + // Build the next chapter cache while the penultimate page is on screen. + if (section->currentPage != section->pageCount - 2) { + return; + } + + const int nextSpineIndex = currentSpineIndex + 1; + if (nextSpineIndex < 0 || nextSpineIndex >= epub->getSpineItemsCount()) { + return; + } + + Section nextSection(epub, nextSpineIndex, renderer); + if (nextSection.loadSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering)) { + return; + } + + LOG_DBG("ERS", "Silently indexing next chapter: %d", nextSpineIndex); + if (!nextSection.createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), + SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, + viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle, + SETTINGS.imageRendering)) { + LOG_ERR("ERS", "Failed silent indexing for chapter: %d", nextSpineIndex); + } +} + void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) { FsFile f; if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 316677ba..bc6c8354 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -41,6 +41,7 @@ class EpubReaderActivity final : public Activity { void renderContents(std::unique_ptr page, int orientedMarginTop, int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft); void renderStatusBar() const; + void silentIndexNextChapterIfNeeded(uint16_t viewportWidth, uint16_t viewportHeight); void saveProgress(int spineIndex, int currentPage, int pageCount); // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); From a01bb2902e830e8df8c4428bef52a86ea5d0c124 Mon Sep 17 00:00:00 2001 From: Zach Nelson Date: Thu, 19 Mar 2026 23:20:50 -0500 Subject: [PATCH 4/4] chore: Removed unused PlatformIO include directory placeholder (#1417) ## Summary **What is the goal of this PR?** This change deletes include/README, which is a PlatformIO boilerplate placeholder file explaining what header files are. The include/ directory isn't used by this project (headers live in lib/ and src/), so this is just cleanup. ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**NO**_ --- include/README | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 include/README diff --git a/include/README b/include/README deleted file mode 100644 index 49819c0d..00000000 --- a/include/README +++ /dev/null @@ -1,37 +0,0 @@ - -This directory is intended for project header files. - -A header file is a file containing C declarations and macro definitions -to be shared between several project source files. You request the use of a -header file in your project source file (C, C++, etc) located in `src` folder -by including it, with the C preprocessing directive `#include'. - -```src/main.c - -#include "header.h" - -int main (void) -{ - ... -} -``` - -Including a header file produces the same results as copying the header file -into each source file that needs it. Such copying would be time-consuming -and error-prone. With a header file, the related declarations appear -in only one place. If they need to be changed, they can be changed in one -place, and programs that include the header file will automatically use the -new version when next recompiled. The header file eliminates the labor of -finding and changing all the copies as well as the risk that a failure to -find one copy will result in inconsistencies within a program. - -In C, the convention is to give header files names that end with `.h'. - -Read more about using header files in official GCC documentation: - -* Include Syntax -* Include Operation -* Once-Only Headers -* Computed Includes - -https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html