From 32d747c6da760a31fb621faf9c355bef002c31c0 Mon Sep 17 00:00:00 2001 From: Tannay Date: Fri, 19 Dec 2025 22:28:17 -0500 Subject: [PATCH] Rotation Support --- lib/Epub/Epub/Section.cpp | 22 ++-- lib/Epub/Epub/Section.h | 6 +- lib/GfxRenderer/GfxRenderer.cpp | 105 +++++++++++++++---- lib/GfxRenderer/GfxRenderer.h | 16 ++- src/CrossPointSettings.cpp | 11 +- src/CrossPointSettings.h | 5 + src/activities/reader/EpubReaderActivity.cpp | 26 ++++- src/activities/settings/SettingsActivity.cpp | 4 +- src/activities/settings/SettingsActivity.h | 2 +- 9 files changed, 158 insertions(+), 39 deletions(-) diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 7c9d241..0ceb544 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -10,8 +10,8 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 5; -} +constexpr uint8_t SECTION_FILE_VERSION = 6; +} // namespace void Section::onPageComplete(std::unique_ptr page) { const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; @@ -27,7 +27,8 @@ void Section::onPageComplete(std::unique_ptr page) { void Section::writeCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) const { + const bool extraParagraphSpacing, const int screenWidth, + const int screenHeight) const { std::ofstream outputFile(("/sd" + cachePath + "/section.bin").c_str()); serialization::writePod(outputFile, SECTION_FILE_VERSION); serialization::writePod(outputFile, fontId); @@ -37,13 +38,15 @@ void Section::writeCacheMetadata(const int fontId, const float lineCompression, serialization::writePod(outputFile, marginBottom); serialization::writePod(outputFile, marginLeft); serialization::writePod(outputFile, extraParagraphSpacing); + serialization::writePod(outputFile, screenWidth); + serialization::writePod(outputFile, screenHeight); serialization::writePod(outputFile, pageCount); outputFile.close(); } bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) { + const bool extraParagraphSpacing, const int screenWidth, const int screenHeight) { if (!SD.exists(cachePath.c_str())) { return false; } @@ -69,6 +72,7 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c int fileFontId, fileMarginTop, fileMarginRight, fileMarginBottom, fileMarginLeft; float fileLineCompression; bool fileExtraParagraphSpacing; + int fileScreenWidth, fileScreenHeight; serialization::readPod(inputFile, fileFontId); serialization::readPod(inputFile, fileLineCompression); serialization::readPod(inputFile, fileMarginTop); @@ -76,10 +80,13 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c serialization::readPod(inputFile, fileMarginBottom); serialization::readPod(inputFile, fileMarginLeft); serialization::readPod(inputFile, fileExtraParagraphSpacing); + serialization::readPod(inputFile, fileScreenWidth); + serialization::readPod(inputFile, fileScreenHeight); if (fontId != fileFontId || lineCompression != fileLineCompression || marginTop != fileMarginTop || marginRight != fileMarginRight || marginBottom != fileMarginBottom || marginLeft != fileMarginLeft || - extraParagraphSpacing != fileExtraParagraphSpacing) { + extraParagraphSpacing != fileExtraParagraphSpacing || screenWidth != fileScreenWidth || + screenHeight != fileScreenHeight) { inputFile.close(); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); clearCache(); @@ -116,7 +123,7 @@ bool Section::clearCache() const { bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, const int marginRight, const int marginBottom, const int marginLeft, - const bool extraParagraphSpacing) { + const bool extraParagraphSpacing, const int screenWidth, const int screenHeight) { const auto localPath = epub->getSpineItem(spineIndex); // TODO: Should we get rid of this file all together? @@ -147,7 +154,8 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression, return false; } - writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing); + writeCacheMetadata(fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft, extraParagraphSpacing, + screenWidth, screenHeight); return true; } diff --git a/lib/Epub/Epub/Section.h b/lib/Epub/Epub/Section.h index 35a17df..528ba7c 100644 --- a/lib/Epub/Epub/Section.h +++ b/lib/Epub/Epub/Section.h @@ -13,7 +13,7 @@ class Section { std::string cachePath; void writeCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing) const; + int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight) const; void onPageComplete(std::unique_ptr page); public: @@ -26,10 +26,10 @@ class Section { } ~Section() = default; bool loadCacheMetadata(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing); + int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight); void setupCacheDir() const; bool clearCache() const; bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, - int marginLeft, bool extraParagraphSpacing); + int marginLeft, bool extraParagraphSpacing, int screenWidth, int screenHeight); std::unique_ptr loadPageFromSD() const; }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 19c959f..ae328c7 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2,6 +2,9 @@ #include +// Default to portrait orientation for all callers +GfxRenderer::Orientation GfxRenderer::orientation = GfxRenderer::Orientation::Portrait; + void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { @@ -13,15 +16,35 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { return; } - // Rotate coordinates: portrait (480x800) -> landscape (800x480) - // Rotation: 90 degrees clockwise - const int rotatedX = y; - const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + int rotatedX = 0; + int rotatedY = 0; - // Bounds checking (portrait: 480x800) + switch (orientation) { + case Orientation::Portrait: { + // Logical portrait (480x800) → panel (800x480) + // Rotation: 90 degrees clockwise + rotatedX = y; + rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x; + break; + } + case Orientation::LandscapeNormal: { + // Logical landscape (800x480) aligned with panel orientation + rotatedX = x; + rotatedY = y; + break; + } + case Orientation::LandscapeFlipped: { + // Logical landscape (800x480) rotated 180° (swap top/bottom and left/right) + rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x; + rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y; + break; + } + } + + // Bounds checking against physical panel dimensions if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= EInkDisplay::DISPLAY_HEIGHT) { - Serial.printf("[%lu] [GFX] !! Outside range (%d, %d)\n", millis(), x, y); + Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY); return; } @@ -115,8 +138,17 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { - // Flip X and Y for portrait mode - einkDisplay.drawImage(bitmap, y, x, height, width); + switch (orientation) { + case Orientation::Portrait: + // Flip X and Y for portrait mode + einkDisplay.drawImage(bitmap, y, x, height, width); + break; + case Orientation::LandscapeNormal: + case Orientation::LandscapeFlipped: + // Native landscape coordinates + einkDisplay.drawImage(bitmap, x, y, width, height); + break; + } } void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, @@ -193,22 +225,55 @@ void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) cons } void GfxRenderer::displayWindow(const int x, const int y, const int width, const int height) const { - // Rotate coordinates from portrait (480x800) to landscape (800x480) - // Rotation: 90 degrees clockwise - // Portrait coordinates: (x, y) with dimensions (width, height) - // Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight) + switch (orientation) { + case Orientation::Portrait: { + // Rotate coordinates from portrait (480x800) to landscape (800x480) + // Rotation: 90 degrees clockwise + // Portrait coordinates: (x, y) with dimensions (width, height) + // Landscape coordinates: (rotatedX, rotatedY) with dimensions (rotatedWidth, rotatedHeight) - const int rotatedX = y; - const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1; - const int rotatedWidth = height; - const int rotatedHeight = width; + const int rotatedX = y; + const int rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x - width + 1; + const int rotatedWidth = height; + const int rotatedHeight = width; - einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight); + einkDisplay.displayWindow(rotatedX, rotatedY, rotatedWidth, rotatedHeight); + break; + } + case Orientation::LandscapeNormal: + case Orientation::LandscapeFlipped: + // Native landscape coordinates + einkDisplay.displayWindow(x, y, width, height); + break; + } } -// Note: Internal driver treats screen in command orientation, this library treats in portrait orientation -int GfxRenderer::getScreenWidth() { return EInkDisplay::DISPLAY_HEIGHT; } -int GfxRenderer::getScreenHeight() { return EInkDisplay::DISPLAY_WIDTH; } +// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation +int GfxRenderer::getScreenWidth() { + switch (orientation) { + case Orientation::Portrait: + // 480px wide in portrait logical coordinates + return EInkDisplay::DISPLAY_HEIGHT; + case Orientation::LandscapeNormal: + case Orientation::LandscapeFlipped: + // 800px wide in landscape logical coordinates + return EInkDisplay::DISPLAY_WIDTH; + } + return EInkDisplay::DISPLAY_HEIGHT; +} + +int GfxRenderer::getScreenHeight() { + switch (orientation) { + case Orientation::Portrait: + // 800px tall in portrait logical coordinates + return EInkDisplay::DISPLAY_WIDTH; + case Orientation::LandscapeNormal: + case Orientation::LandscapeFlipped: + // 480px tall in landscape logical coordinates + return EInkDisplay::DISPLAY_HEIGHT; + } + return EInkDisplay::DISPLAY_WIDTH; +} int GfxRenderer::getSpaceWidth(const int fontId) const { if (fontMap.count(fontId) == 0) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 838e018..96ea3e9 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -12,12 +12,22 @@ class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; + // Logical screen orientation from the perspective of callers + enum class Orientation { + Portrait, // 480x800 logical coordinates (current default) + LandscapeNormal, // 800x480 logical coordinates, native panel orientation + LandscapeFlipped // 800x480 logical coordinates, rotated 180° (swap top/bottom) + }; + private: static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE; static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE, "BW buffer chunking does not line up with display buffer size"); + // Global orientation used for all rendering operations + static Orientation orientation; + EInkDisplay& einkDisplay; RenderMode renderMode; uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; @@ -33,11 +43,15 @@ class GfxRenderer { // Setup void insertFont(int fontId, EpdFontFamily font); + // Orientation control (affects logical width/height and coordinate transforms) + static void setOrientation(Orientation o) { orientation = o; } + static Orientation getOrientation() { return orientation; } + // Screen ops static int getScreenWidth(); static int getScreenHeight(); void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const; - // EXPERIMENTAL: Windowed update - display only a rectangular region (portrait coordinates) + // EXPERIMENTAL: Windowed update - display only a rectangular region void displayWindow(int x, int y, int width, int height) const; void invertScreen() const; void clearScreen(uint8_t color = 0xFF) const; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 8959072..4b3c9f8 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -12,7 +12,8 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; -constexpr uint8_t SETTINGS_COUNT = 3; +// Increment this when adding new persisted settings fields +constexpr uint8_t SETTINGS_COUNT = 5; constexpr char SETTINGS_FILE[] = "/sd/.crosspoint/settings.bin"; } // namespace @@ -26,6 +27,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, whiteSleepScreen); serialization::writePod(outputFile, extraParagraphSpacing); serialization::writePod(outputFile, shortPwrBtn); + serialization::writePod(outputFile, landscapeReading); + serialization::writePod(outputFile, landscapeFlipped); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -51,7 +54,7 @@ bool CrossPointSettings::loadFromFile() { uint8_t fileSettingsCount = 0; serialization::readPod(inputFile, fileSettingsCount); - // load settings that exist + // load settings that exist (support older files with fewer fields) uint8_t settingsRead = 0; do { serialization::readPod(inputFile, whiteSleepScreen); @@ -60,6 +63,10 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, shortPwrBtn); if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, landscapeReading); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, landscapeFlipped); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d6ad766..a43e57a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -21,6 +21,11 @@ class CrossPointSettings { uint8_t extraParagraphSpacing = 1; // Duration of the power button press uint8_t shortPwrBtn = 0; + // EPUB reading orientation settings + // 0 = portrait (default), 1 = landscape + uint8_t landscapeReading = 0; + // When in landscape mode: 0 = normal, 1 = flipped (swap top/bottom) + uint8_t landscapeFlipped = 0; ~CrossPointSettings() = default; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fc4504d..84e7f8d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -29,6 +29,17 @@ void EpubReaderActivity::onEnter() { return; } + // Configure screen orientation based on settings + if (SETTINGS.landscapeReading) { + if (SETTINGS.landscapeFlipped) { + GfxRenderer::setOrientation(GfxRenderer::Orientation::LandscapeFlipped); + } else { + GfxRenderer::setOrientation(GfxRenderer::Orientation::LandscapeNormal); + } + } else { + GfxRenderer::setOrientation(GfxRenderer::Orientation::Portrait); + } + renderingMutex = xSemaphoreCreateMutex(); epub->setupCacheDir(); @@ -56,6 +67,9 @@ void EpubReaderActivity::onEnter() { } void EpubReaderActivity::onExit() { + // Reset orientation back to portrait for the rest of the UI + GfxRenderer::setOrientation(GfxRenderer::Orientation::Portrait); + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { @@ -208,7 +222,8 @@ void EpubReaderActivity::renderScreen() { Serial.printf("[%lu] [ERS] Loading file: %s, index: %d\n", millis(), filepath.c_str(), currentSpineIndex); section = std::unique_ptr
(new Section(epub, currentSpineIndex, renderer)); if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, marginLeft, - SETTINGS.extraParagraphSpacing)) { + SETTINGS.extraParagraphSpacing, GfxRenderer::getScreenWidth(), + GfxRenderer::getScreenHeight())) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); { @@ -227,7 +242,8 @@ void EpubReaderActivity::renderScreen() { section->setupCacheDir(); if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, - marginLeft, SETTINGS.extraParagraphSpacing)) { + marginLeft, SETTINGS.extraParagraphSpacing, GfxRenderer::getScreenWidth(), + GfxRenderer::getScreenHeight())) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; @@ -322,7 +338,9 @@ void EpubReaderActivity::renderContents(std::unique_ptr page) { } void EpubReaderActivity::renderStatusBar() const { - constexpr auto textY = 776; + // Position status bar near the bottom of the logical screen, regardless of orientation + const auto screenHeight = GfxRenderer::getScreenHeight(); + const auto textY = screenHeight - 24; // Calculate progress in book float sectionChapterProg = static_cast(section->currentPage) / section->pageCount; @@ -345,7 +363,7 @@ void EpubReaderActivity::renderStatusBar() const { constexpr int batteryWidth = 15; constexpr int batteryHeight = 10; constexpr int x = marginLeft; - constexpr int y = 783; + const int y = screenHeight - 17; // Top line renderer.drawLine(x, y, x + batteryWidth - 4, y); diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b3acf3f..c865de0 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -10,7 +10,9 @@ const SettingInfo SettingsActivity::settingsList[settingsCount] = { {"White Sleep Screen", SettingType::TOGGLE, &CrossPointSettings::whiteSleepScreen}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing}, - {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}}; + {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn}, + {"Landscape Reading", SettingType::TOGGLE, &CrossPointSettings::landscapeReading}, + {"Flip Landscape (swap top/bottom)", SettingType::TOGGLE, &CrossPointSettings::landscapeFlipped}}; void SettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 7843a5c..19df818 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -29,7 +29,7 @@ class SettingsActivity final : public Activity { const std::function onGoHome; // Static settings list - static constexpr int settingsCount = 3; // Number of settings + static constexpr int settingsCount = 5; // Number of settings static const SettingInfo settingsList[settingsCount]; static void taskTrampoline(void* param);