Rotation Support

This commit is contained in:
Tannay 2025-12-19 22:28:17 -05:00
parent cfe838e03b
commit 32d747c6da
9 changed files with 158 additions and 39 deletions

View File

@ -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> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin";
@ -27,7 +27,8 @@ void Section::onPageComplete(std::unique_ptr<Page> 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;
}

View File

@ -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> 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<Page> loadPageFromSD() const;
};

View File

@ -2,6 +2,9 @@
#include <Utf8.h>
// 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) {

View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -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<Section>(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> 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<float>(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);

View File

@ -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<SettingsActivity*>(param);

View File

@ -29,7 +29,7 @@ class SettingsActivity final : public Activity {
const std::function<void()> 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);