diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp index 465593e8..32d2cf3c 100644 --- a/lib/GfxRenderer/BitmapHelpers.cpp +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) { const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 return (gray >= adjustedThreshold) ? 1 : 0; } + +// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config. +// Produces smooth-looking gradients on the 4-level e-ink display. +uint8_t quantizeNoiseDither(int gray, int x, int y) { + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + + const int scaled = gray * 3; + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h index 791e70b9..311e5674 100644 --- a/lib/GfxRenderer/BitmapHelpers.h +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y); uint8_t quantizeSimple(int gray); uint8_t quantize1bit(int gray, int x, int y); int adjustPixel(int gray); +uint8_t quantizeNoiseDither(int gray, int x, int y); // 1-bit Atkinson dithering - better quality than noise dithering for thumbnails // Error distribution pattern (same as 2-bit but quantizes to 2 levels): diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 14024bc4..4128b7fc 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -72,6 +72,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { } } +void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const { + if (renderMode == BW && val2bit < 3) { + drawPixel(x, y); + } else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) { + drawPixel(x, y, false); + } else if (renderMode == GRAYSCALE_LSB && val2bit == 1) { + drawPixel(x, y, false); + } +} + int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { if (fontMap.count(fontId) == 0) { Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); @@ -422,12 +432,20 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); - if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { - scale = static_cast(maxWidth) / static_cast((1.0f - cropX) * bitmap.getWidth()); + const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); + const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight(); + + // Calculate scale factor: supports both downscaling and upscaling when both constraints are provided + if (maxWidth > 0 && maxHeight > 0) { + const float scaleX = static_cast(maxWidth) / effectiveWidth; + const float scaleY = static_cast(maxHeight) / effectiveHeight; + scale = std::min(scaleX, scaleY); + isScaled = (scale < 0.999f || scale > 1.001f); + } else if (maxWidth > 0 && effectiveWidth > static_cast(maxWidth)) { + scale = static_cast(maxWidth) / effectiveWidth; isScaled = true; - } - if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast((1.0f - cropY) * bitmap.getHeight())); + } else if (maxHeight > 0 && effectiveHeight > static_cast(maxHeight)) { + scale = static_cast(maxHeight) / effectiveHeight; isScaled = true; } Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled"); @@ -448,12 +466,17 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) { // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). // Screen's (0, 0) is the top-left corner. - int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); + const int logicalY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); + int screenYStart, screenYEnd; if (isScaled) { - screenY = std::floor(screenY * scale); + screenYStart = static_cast(std::floor(logicalY * scale)) + y; + screenYEnd = static_cast(std::floor((logicalY + 1) * scale)) + y; + } else { + screenYStart = logicalY + y; + screenYEnd = screenYStart + 1; } - screenY += y; // the offset should not be scaled - if (screenY >= getScreenHeight()) { + + if (screenYStart >= getScreenHeight()) { break; } @@ -464,7 +487,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con return; } - if (screenY < 0) { + if (screenYEnd <= 0) { continue; } @@ -473,27 +496,42 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con continue; } + const int syStart = std::max(screenYStart, 0); + const int syEnd = std::min(screenYEnd, getScreenHeight()); + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { - int screenX = bmpX - cropPixX; + const int outX = bmpX - cropPixX; + int screenXStart, screenXEnd; if (isScaled) { - screenX = std::floor(screenX * scale); + screenXStart = static_cast(std::floor(outX * scale)) + x; + screenXEnd = static_cast(std::floor((outX + 1) * scale)) + x; + } else { + screenXStart = outX + x; + screenXEnd = screenXStart + 1; } - screenX += x; // the offset should not be scaled - if (screenX >= getScreenWidth()) { + + if (screenXStart >= getScreenWidth()) { break; } - if (screenX < 0) { + if (screenXEnd <= 0) { continue; } const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; - if (renderMode == BW && val < 3) { - drawPixel(screenX, screenY); - } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { - drawPixel(screenX, screenY, false); - } else if (renderMode == GRAYSCALE_LSB && val == 1) { - drawPixel(screenX, screenY, false); + const int sxStart = std::max(screenXStart, 0); + const int sxEnd = std::min(screenXEnd, getScreenWidth()); + + for (int sy = syStart; sy < syEnd; sy++) { + for (int sx = sxStart; sx < sxEnd; sx++) { + if (renderMode == BW && val < 3) { + drawPixel(sx, sy); + } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { + drawPixel(sx, sy, false); + } else if (renderMode == GRAYSCALE_LSB && val == 1) { + drawPixel(sx, sy, false); + } + } } } } @@ -506,11 +544,16 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxHeight) const { float scale = 1.0f; bool isScaled = false; - if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { + // Calculate scale factor: supports both downscaling and upscaling when both constraints are provided + if (maxWidth > 0 && maxHeight > 0) { + const float scaleX = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + const float scaleY = static_cast(maxHeight) / static_cast(bitmap.getHeight()); + scale = std::min(scaleX, scaleY); + isScaled = (scale < 0.999f || scale > 1.001f); + } else if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); isScaled = true; - } - if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { + } else if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); isScaled = true; } @@ -538,20 +581,37 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, // Calculate screen Y based on whether BMP is top-down or bottom-up const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; - int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); - if (screenY >= getScreenHeight()) { + int screenYStart, screenYEnd; + if (isScaled) { + screenYStart = static_cast(std::floor(bmpYOffset * scale)) + y; + screenYEnd = static_cast(std::floor((bmpYOffset + 1) * scale)) + y; + } else { + screenYStart = bmpYOffset + y; + screenYEnd = screenYStart + 1; + } + if (screenYStart >= getScreenHeight()) { continue; // Continue reading to keep row counter in sync } - if (screenY < 0) { + if (screenYEnd <= 0) { continue; } + const int syStart = std::max(screenYStart, 0); + const int syEnd = std::min(screenYEnd, getScreenHeight()); + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { - int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); - if (screenX >= getScreenWidth()) { + int screenXStart, screenXEnd; + if (isScaled) { + screenXStart = static_cast(std::floor(bmpX * scale)) + x; + screenXEnd = static_cast(std::floor((bmpX + 1) * scale)) + x; + } else { + screenXStart = bmpX + x; + screenXEnd = screenXStart + 1; + } + if (screenXStart >= getScreenWidth()) { break; } - if (screenX < 0) { + if (screenXEnd <= 0) { continue; } @@ -561,7 +621,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) // val < 3 means black pixel (draw it) if (val < 3) { - drawPixel(screenX, screenY, true); + const int sxStart = std::max(screenXStart, 0); + const int sxEnd = std::min(screenXEnd, getScreenWidth()); + for (int sy = syStart; sy < syEnd; sy++) { + for (int sx = sxStart; sx < sxEnd; sx++) { + drawPixel(sx, sy, true); + } + } } // White pixels (val == 3) are not drawn (leave background) } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 4540774e..c263973d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -77,6 +77,7 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) const; + void drawPixelGray(int x, int y, uint8_t val2bit) 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) const; void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index ffeef2b9..1ecf6234 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 30; +constexpr uint8_t SETTINGS_COUNT = 32; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; // Validate front button mapping to ensure each hardware button is unique. @@ -118,6 +118,8 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, frontButtonRight); serialization::writePod(outputFile, fadingFix); serialization::writePod(outputFile, embeddedStyle); + serialization::writePod(outputFile, sleepScreenLetterboxFill); + serialization::writePod(outputFile, sleepScreenGradientDir); // New fields added at end for backward compatibility outputFile.close(); @@ -223,6 +225,10 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, embeddedStyle); if (++settingsRead >= fileSettingsCount) break; + readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT); + if (++settingsRead >= fileSettingsCount) break; + readAndValidate(inputFile, sleepScreenGradientDir, SLEEP_SCREEN_GRADIENT_DIR_COUNT); + if (++settingsRead >= fileSettingsCount) break; // New fields added at end for backward compatibility } while (false); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1348519f..317c19d5 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -31,6 +31,14 @@ class CrossPointSettings { INVERTED_BLACK_AND_WHITE = 2, SLEEP_SCREEN_COVER_FILTER_COUNT }; + enum SLEEP_SCREEN_LETTERBOX_FILL { + LETTERBOX_NONE = 0, + LETTERBOX_SOLID = 1, + LETTERBOX_BLENDED = 2, + LETTERBOX_GRADIENT = 3, + SLEEP_SCREEN_LETTERBOX_FILL_COUNT + }; + enum SLEEP_SCREEN_GRADIENT_DIR { GRADIENT_TO_WHITE = 0, GRADIENT_TO_BLACK = 1, SLEEP_SCREEN_GRADIENT_DIR_COUNT }; // Status bar display type enum enum STATUS_BAR_MODE { @@ -125,6 +133,10 @@ class CrossPointSettings { uint8_t sleepScreenCoverMode = FIT; // Sleep screen cover filter uint8_t sleepScreenCoverFilter = NO_FILTER; + // Sleep screen letterbox fill mode (None / Solid / Blended / Gradient) + uint8_t sleepScreenLetterboxFill = LETTERBOX_GRADIENT; + // Sleep screen gradient direction (towards white or black) + uint8_t sleepScreenGradientDir = GRADIENT_TO_WHITE; // Status bar settings uint8_t statusBar = FULL; // Text rendering settings diff --git a/src/SettingsList.h b/src/SettingsList.h index e493f40f..fbf0f5f8 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -18,6 +18,10 @@ inline std::vector getSettingsList() { "sleepScreenCoverMode", "Display"), SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, {"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"), + SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill, + {"None", "Solid", "Blended", "Gradient"}, "sleepScreenLetterboxFill", "Display"), + SettingInfo::Enum("Gradient Direction", &CrossPointSettings::sleepScreenGradientDir, {"To White", "To Black"}, + "sleepScreenGradientDir", "Display"), SettingInfo::Enum( "Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"}, diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 846df22d..3e88e3ac 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,11 +1,15 @@ #include "SleepActivity.h" +#include #include #include #include +#include #include #include +#include + #include "CrossPointSettings.h" #include "CrossPointState.h" #include "components/UITheme.h" @@ -13,6 +17,364 @@ #include "images/Logo120.h" #include "util/StringUtils.h" +namespace { + +// Number of source pixels along the image edge to average for the gradient color +constexpr int EDGE_SAMPLE_DEPTH = 20; + +// Map a 2-bit quantized pixel value to an 8-bit grayscale value +constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; } + +// Edge gradient data produced by sampleBitmapEdges and consumed by drawLetterboxGradients. +// edgeA is the "first" edge (top or left), edgeB is the "second" edge (bottom or right). +struct LetterboxGradientData { + uint8_t* edgeA = nullptr; + uint8_t* edgeB = nullptr; + int edgeCount = 0; + int letterboxA = 0; // pixel size of the first letterbox area (top or left) + int letterboxB = 0; // pixel size of the second letterbox area (bottom or right) + bool horizontal = false; // true = top/bottom letterbox, false = left/right + + void free() { + ::free(edgeA); + ::free(edgeB); + edgeA = nullptr; + edgeB = nullptr; + } +}; + +// Binary cache version for edge data files +constexpr uint8_t EDGE_CACHE_VERSION = 1; + +// Load cached edge data from a binary file. Returns true if the cache was valid and loaded successfully. +// Validates cache version and screen dimensions to detect stale data. +bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxGradientData& data) { + FsFile file; + if (!Storage.openFileForRead("SLP", path, file)) return false; + + uint8_t version; + serialization::readPod(file, version); + if (version != EDGE_CACHE_VERSION) { + file.close(); + return false; + } + + uint16_t cachedW, cachedH; + serialization::readPod(file, cachedW); + serialization::readPod(file, cachedH); + if (cachedW != static_cast(screenWidth) || cachedH != static_cast(screenHeight)) { + file.close(); + return false; + } + + uint8_t horizontal; + serialization::readPod(file, horizontal); + data.horizontal = (horizontal != 0); + + uint16_t edgeCount; + serialization::readPod(file, edgeCount); + data.edgeCount = edgeCount; + + int16_t lbA, lbB; + serialization::readPod(file, lbA); + serialization::readPod(file, lbB); + data.letterboxA = lbA; + data.letterboxB = lbB; + + if (edgeCount == 0 || edgeCount > 2048) { + file.close(); + return false; + } + + data.edgeA = static_cast(malloc(edgeCount)); + data.edgeB = static_cast(malloc(edgeCount)); + if (!data.edgeA || !data.edgeB) { + data.free(); + file.close(); + return false; + } + + if (file.read(data.edgeA, edgeCount) != static_cast(edgeCount) || + file.read(data.edgeB, edgeCount) != static_cast(edgeCount)) { + data.free(); + file.close(); + return false; + } + + file.close(); + Serial.printf("[%lu] [SLP] Loaded edge cache from %s (%d edges)\n", millis(), path.c_str(), edgeCount); + return true; +} + +// Save edge data to a binary cache file for reuse on subsequent sleep screens. +bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxGradientData& data) { + if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return false; + + FsFile file; + if (!Storage.openFileForWrite("SLP", path, file)) return false; + + serialization::writePod(file, EDGE_CACHE_VERSION); + serialization::writePod(file, static_cast(screenWidth)); + serialization::writePod(file, static_cast(screenHeight)); + serialization::writePod(file, static_cast(data.horizontal ? 1 : 0)); + serialization::writePod(file, static_cast(data.edgeCount)); + serialization::writePod(file, static_cast(data.letterboxA)); + serialization::writePod(file, static_cast(data.letterboxB)); + file.write(data.edgeA, data.edgeCount); + file.write(data.edgeB, data.edgeCount); + file.close(); + + Serial.printf("[%lu] [SLP] Saved edge cache to %s (%d edges)\n", millis(), path.c_str(), data.edgeCount); + return true; +} + +// Read the bitmap once to sample the first/last EDGE_SAMPLE_DEPTH rows or columns. +// Returns edge color arrays in source pixel resolution. Caller must call data.free() when done. +// After sampling the bitmap is rewound via rewindToData(). +LetterboxGradientData sampleBitmapEdges(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight, + float scale, float cropX, float cropY) { + LetterboxGradientData data; + + const int cropPixX = static_cast(std::floor(bitmap.getWidth() * cropX / 2.0f)); + const int cropPixY = static_cast(std::floor(bitmap.getHeight() * cropY / 2.0f)); + const int visibleWidth = bitmap.getWidth() - 2 * cropPixX; + const int visibleHeight = bitmap.getHeight() - 2 * cropPixY; + + if (visibleWidth <= 0 || visibleHeight <= 0) return data; + + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + if (!outputRow || !rowBytes) { + ::free(outputRow); + ::free(rowBytes); + return data; + } + + if (imgY > 0) { + // Top/bottom letterboxing -- sample per-column averages of first/last N rows + data.horizontal = true; + data.edgeCount = visibleWidth; + const int scaledHeight = static_cast(std::round(static_cast(visibleHeight) * scale)); + data.letterboxA = imgY; + data.letterboxB = pageHeight - imgY - scaledHeight; + if (data.letterboxB < 0) data.letterboxB = 0; + + const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight); + + auto* accumTop = static_cast(calloc(visibleWidth, sizeof(uint32_t))); + auto* accumBot = static_cast(calloc(visibleWidth, sizeof(uint32_t))); + data.edgeA = static_cast(malloc(visibleWidth)); + data.edgeB = static_cast(malloc(visibleWidth)); + + if (!accumTop || !accumBot || !data.edgeA || !data.edgeB) { + ::free(accumTop); + ::free(accumBot); + data.free(); + ::free(outputRow); + ::free(rowBytes); + return data; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; + const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; + const int outY = logicalY - cropPixY; + + const bool inTop = (outY < sampleRows); + const bool inBot = (outY >= visibleHeight - sampleRows); + if (!inTop && !inBot) continue; + + for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + const int outX = bmpX - cropPixX; + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + const uint8_t gray = val2bitToGray(val); + if (inTop) accumTop[outX] += gray; + if (inBot) accumBot[outX] += gray; + } + } + + for (int i = 0; i < visibleWidth; i++) { + data.edgeA[i] = static_cast(accumTop[i] / sampleRows); + data.edgeB[i] = static_cast(accumBot[i] / sampleRows); + } + ::free(accumTop); + ::free(accumBot); + + } else if (imgX > 0) { + // Left/right letterboxing -- sample per-row averages of first/last N columns + data.horizontal = false; + data.edgeCount = visibleHeight; + const int scaledWidth = static_cast(std::round(static_cast(visibleWidth) * scale)); + data.letterboxA = imgX; + data.letterboxB = pageWidth - imgX - scaledWidth; + if (data.letterboxB < 0) data.letterboxB = 0; + + const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth); + + auto* accumLeft = static_cast(calloc(visibleHeight, sizeof(uint32_t))); + auto* accumRight = static_cast(calloc(visibleHeight, sizeof(uint32_t))); + data.edgeA = static_cast(malloc(visibleHeight)); + data.edgeB = static_cast(malloc(visibleHeight)); + + if (!accumLeft || !accumRight || !data.edgeA || !data.edgeB) { + ::free(accumLeft); + ::free(accumRight); + data.free(); + ::free(outputRow); + ::free(rowBytes); + return data; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break; + const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue; + const int outY = logicalY - cropPixY; + + // Sample left edge columns + for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) { + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + accumLeft[outY] += val2bitToGray(val); + } + // Sample right edge columns + for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) { + const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3; + accumRight[outY] += val2bitToGray(val); + } + } + + for (int i = 0; i < visibleHeight; i++) { + data.edgeA[i] = static_cast(accumLeft[i] / sampleCols); + data.edgeB[i] = static_cast(accumRight[i] / sampleCols); + } + ::free(accumLeft); + ::free(accumRight); + } + + ::free(outputRow); + ::free(rowBytes); + bitmap.rewindToData(); + return data; +} + +// Draw dithered fills in the letterbox areas using the sampled edge colors. +// fillMode selects the fill algorithm: SOLID (single dominant shade), BLENDED (per-pixel edge color), +// or GRADIENT (per-pixel edge color interpolated toward targetColor). +// targetColor is the color the gradient fades toward (255=white, 0=black); only used in GRADIENT mode. +// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB). +void drawLetterboxFill(GfxRenderer& renderer, const LetterboxGradientData& data, float scale, uint8_t fillMode, + int targetColor) { + if (!data.edgeA || !data.edgeB || data.edgeCount <= 0) return; + + const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID); + const bool isGradient = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_GRADIENT); + + // For SOLID mode, compute the dominant (average) shade for each edge once + uint8_t solidColorA = 0, solidColorB = 0; + if (isSolid) { + uint32_t sumA = 0, sumB = 0; + for (int i = 0; i < data.edgeCount; i++) { + sumA += data.edgeA[i]; + sumB += data.edgeB[i]; + } + solidColorA = static_cast(sumA / data.edgeCount); + solidColorB = static_cast(sumB / data.edgeCount); + } + + // Helper: compute gray value for a pixel given the edge color and interpolation factor t (0..1) + // GRADIENT interpolates from edgeColor toward targetColor; SOLID and BLENDED return edgeColor directly. + auto computeGray = [&](int edgeColor, float t) -> int { + if (isGradient) return edgeColor + static_cast(static_cast(targetColor - edgeColor) * t); + return edgeColor; + }; + + if (data.horizontal) { + // Top letterbox + if (data.letterboxA > 0) { + const int imgTopY = data.letterboxA; + for (int screenY = 0; screenY < imgTopY; screenY++) { + const float t = static_cast(imgTopY - screenY) / static_cast(imgTopY); + for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) { + int edgeColor; + if (isSolid) { + edgeColor = solidColorA; + } else { + int srcCol = static_cast(screenX / scale); + srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1)); + edgeColor = data.edgeA[srcCol]; + } + const int gray = computeGray(edgeColor, t); + renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY)); + } + } + } + + // Bottom letterbox + if (data.letterboxB > 0) { + const int imgBottomY = renderer.getScreenHeight() - data.letterboxB; + for (int screenY = imgBottomY; screenY < renderer.getScreenHeight(); screenY++) { + const float t = static_cast(screenY - imgBottomY + 1) / static_cast(data.letterboxB); + for (int screenX = 0; screenX < renderer.getScreenWidth(); screenX++) { + int edgeColor; + if (isSolid) { + edgeColor = solidColorB; + } else { + int srcCol = static_cast(screenX / scale); + srcCol = std::max(0, std::min(srcCol, data.edgeCount - 1)); + edgeColor = data.edgeB[srcCol]; + } + const int gray = computeGray(edgeColor, t); + renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY)); + } + } + } + } else { + // Left letterbox + if (data.letterboxA > 0) { + const int imgLeftX = data.letterboxA; + for (int screenX = 0; screenX < imgLeftX; screenX++) { + const float t = static_cast(imgLeftX - screenX) / static_cast(imgLeftX); + for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) { + int edgeColor; + if (isSolid) { + edgeColor = solidColorA; + } else { + int srcRow = static_cast(screenY / scale); + srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1)); + edgeColor = data.edgeA[srcRow]; + } + const int gray = computeGray(edgeColor, t); + renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY)); + } + } + } + + // Right letterbox + if (data.letterboxB > 0) { + const int imgRightX = renderer.getScreenWidth() - data.letterboxB; + for (int screenX = imgRightX; screenX < renderer.getScreenWidth(); screenX++) { + const float t = static_cast(screenX - imgRightX + 1) / static_cast(data.letterboxB); + for (int screenY = 0; screenY < renderer.getScreenHeight(); screenY++) { + int edgeColor; + if (isSolid) { + edgeColor = solidColorB; + } else { + int srcRow = static_cast(screenY / scale); + srcRow = std::max(0, std::min(srcRow, data.edgeCount - 1)); + edgeColor = data.edgeB[srcRow]; + } + const int gray = computeGray(edgeColor, t); + renderer.drawPixelGray(screenX, screenY, quantizeNoiseDither(gray, screenX, screenY)); + } + } + } + } +} + +} // namespace + void SleepActivity::onEnter() { Activity::onEnter(); GUI.drawPopup(renderer, "Entering Sleep..."); @@ -121,7 +483,7 @@ void SleepActivity::renderDefaultSleepScreen() const { renderer.displayBuffer(HalDisplay::HALF_REFRESH); } -void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { +void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath) const { int x, y; const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -129,45 +491,79 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight); - if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) { - // image will scale, make sure placement is right - float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); - const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); - Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); - if (ratio > screenRatio) { - // image wider than viewport ratio, scaled down image needs to be centered vertically - if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { - cropX = 1.0f - (screenRatio / ratio); - Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); - ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); - } - x = 0; - y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); - Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); - } else { - // image taller than viewport ratio, scaled down image needs to be centered horizontally - if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { - cropY = 1.0f - (ratio / screenRatio); - Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); - ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); - } - x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); - y = 0; - Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); + // Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images) + float ratio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float screenRatio = static_cast(pageWidth) / static_cast(pageHeight); + + Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio); + if (ratio > screenRatio) { + // image wider than viewport ratio, needs to be centered vertically + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropX = 1.0f - (screenRatio / ratio); + Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX); + ratio = (1.0f - cropX) * static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); } + x = 0; + y = std::round((static_cast(pageHeight) - static_cast(pageWidth) / ratio) / 2); + Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y); } else { - // center the image - x = (pageWidth - bitmap.getWidth()) / 2; - y = (pageHeight - bitmap.getHeight()) / 2; + // image taller than or equal to viewport ratio, needs to be centered horizontally + if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) { + cropY = 1.0f - (ratio / screenRatio); + Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY); + ratio = static_cast(bitmap.getWidth()) / ((1.0f - cropY) * static_cast(bitmap.getHeight())); + } + x = std::round((static_cast(pageWidth) - static_cast(pageHeight) * ratio) / 2); + y = 0; + Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x); } Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); + + // Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords + const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth(); + const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight(); + const float scale = + std::min(static_cast(pageWidth) / effectiveWidth, static_cast(pageHeight) / effectiveHeight); + + // Determine letterbox fill settings + const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill; + const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE); + const int targetColor = + (SETTINGS.sleepScreenGradientDir == CrossPointSettings::SLEEP_SCREEN_GRADIENT_DIR::GRADIENT_TO_BLACK) ? 0 : 255; + + static const char* fillModeNames[] = {"none", "solid", "blended", "gradient"}; + const char* fillModeName = (fillMode < 4) ? fillModeNames[fillMode] : "unknown"; + + // Load cached edge data or sample from bitmap (first pass over bitmap, then rewind) + LetterboxGradientData gradientData; + const bool hasLetterbox = (x > 0 || y > 0); + if (hasLetterbox && wantFill) { + bool cacheLoaded = false; + if (!edgeCachePath.empty()) { + cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData); + } + if (!cacheLoaded) { + Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), sampling edges for %s fill\n", millis(), x, y, + fillModeName); + gradientData = sampleBitmapEdges(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY); + if (!edgeCachePath.empty() && gradientData.edgeA) { + saveEdgeCache(edgeCachePath, pageWidth, pageHeight, gradientData); + } + } + } + renderer.clearScreen(); const bool hasGreyscale = bitmap.hasGreyscale() && SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER; + // Draw letterbox fill (BW pass) + if (gradientData.edgeA) { + drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); + } + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) { @@ -180,18 +576,26 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const { bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + if (gradientData.edgeA) { + drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); + } renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + if (gradientData.edgeA) { + drawLetterboxFill(renderer, gradientData, scale, fillMode, targetColor); + } renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); renderer.displayGrayBuffer(); renderer.setRenderMode(GfxRenderer::BW); } + + gradientData.free(); } void SleepActivity::renderCoverSleepScreen() const { @@ -261,12 +665,18 @@ void SleepActivity::renderCoverSleepScreen() const { return (this->*renderNoCoverSleepScreen)(); } + // Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin) + std::string edgeCachePath; + if (coverBmpPath.size() > 4) { + edgeCachePath = coverBmpPath.substr(0, coverBmpPath.size() - 4) + "_edges.bin"; + } + FsFile file; if (Storage.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[SLP] Rendering sleep cover: %s\n", coverBmpPath.c_str()); - renderBitmapSleepScreen(bitmap); + renderBitmapSleepScreen(bitmap, edgeCachePath); return; } } diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 87df8ba1..7c703e2a 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,4 +1,6 @@ #pragma once +#include + #include "../Activity.h" class Bitmap; @@ -13,6 +15,6 @@ class SleepActivity final : public Activity { void renderDefaultSleepScreen() const; void renderCustomSleepScreen() const; void renderCoverSleepScreen() const; - void renderBitmapSleepScreen(const Bitmap& bitmap) const; + void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "") const; void renderBlankSleepScreen() const; };