diff --git a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h index c938785d..9496d3b9 100644 --- a/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h +++ b/lib/Epub/Epub/converters/ImageToFramebufferDecoder.h @@ -60,7 +60,11 @@ struct RenderConfig { ImageDitherMode ditherMode = ImageDitherMode::Bayer; bool performanceMode = false; bool useExactDimensions = false; // If true, use maxWidth/maxHeight as exact output size (no recalculation) - std::string cachePath; // If non-empty, decoder will write pixel cache to this path + // If true, the decoder uses a 1-bit Atkinson dither and emits only the values 0/3 — suitable for + // pure black-and-white display (no grayscale planes). The 4-level dither path produces values 1/2 + // which the BW DirectPixelWriter collapses to black, making mid-grays render very dark. + bool monochromeOutput = false; + std::string cachePath; // If non-empty, decoder will write pixel cache to this path }; class ImageToFramebufferDecoder { diff --git a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp index e8394f84..0968fb5d 100644 --- a/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/JpegToFramebufferConverter.cpp @@ -1,8 +1,6 @@ #include "JpegToFramebufferConverter.h" -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION #include -#endif #include #include #include @@ -42,6 +40,11 @@ struct JpegContext { PixelCache cache; bool caching; + // See PngContext for the rationale: monochromeOutput requests a 1-bit Atkinson dither + // emitting only 0/3 so the BW DirectPixelWriter (`pixelValue < 3` rule) maps cleanly. + int oneBitDitherRow; + Atkinson1BitDitherer* atkinson1BitDitherer; + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION int currentDitherRow; AtkinsonDitherer* atkinsonDitherer; @@ -59,7 +62,9 @@ struct JpegContext { dstHeight(0), fineScaleFP(1 << 16), invScaleFP(1 << 16), - caching(false) + caching(false), + oneBitDitherRow(-1), + atkinson1BitDitherer(nullptr) #ifdef ENABLE_IMAGE_DITHERING_EXTENSION , currentDitherRow(-1), @@ -69,14 +74,32 @@ struct JpegContext { { } -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION ~JpegContext() { + delete atkinson1BitDitherer; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION delete atkinsonDitherer; delete diffusedBayerDitherer; - } #endif + } }; +// Advance the 1-bit Atkinson ditherer to the requested destination row. +// Handles non-monotonic row walks (block-based JPEG decode) by reset+replay. +void prepareOneBitDitherRow(JpegContext& ctx, int dstY) { + if (!ctx.atkinson1BitDitherer) return; + + if (ctx.oneBitDitherRow == -1 || dstY < ctx.oneBitDitherRow) { + ctx.atkinson1BitDitherer->reset(); + ctx.oneBitDitherRow = dstY; + return; + } + + while (ctx.oneBitDitherRow < dstY) { + ctx.atkinson1BitDitherer->nextRow(); + ctx.oneBitDitherRow++; + } +} + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION void prepareDitherRow(JpegContext& ctx, int dstY) { if (!ctx.config || !ctx.config->useDithering) return; @@ -96,6 +119,10 @@ void prepareDitherRow(JpegContext& ctx, int dstY) { } uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) { + if (ctx.atkinson1BitDitherer) { + return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0; + } + if (!ctx.config || !ctx.config->useDithering) { return quantizeGray4Level(gray); } @@ -121,7 +148,9 @@ uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int out } #else uint8_t ditherGray(JpegContext& ctx, uint8_t gray, int localX, int outX, int outY) { - (void)ctx; + if (ctx.atkinson1BitDitherer) { + return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0; + } (void)localX; return applyBayerDither4Level(gray, outX, outY); } @@ -254,6 +283,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { if (fineScaleFP == FP_ONE) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareOneBitDitherRow(*ctx, dstY); #ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); #endif @@ -285,6 +315,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareOneBitDitherRow(*ctx, dstY); #ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); #endif @@ -367,6 +398,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) { // === Nearest-neighbor (downscale: fineScale < 1.0) === for (int dstY = dstYStart; dstY < dstYEnd; dstY++) { const int outY = cfgY + dstY; + prepareOneBitDitherRow(*ctx, dstY); #ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); #endif @@ -531,7 +563,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat } } - if (config.useDithering) { + // See PngToFramebufferConverter for rationale: BW-only display needs a 1-bit + // dither so mid-grays don't collapse to black under DirectPixelWriter's `< 3` rule. + if (config.monochromeOutput) { + ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(destWidth); + if (!ctx.atkinson1BitDitherer) { + LOG_ERR("JPG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither"); + } + } + + if (config.useDithering && !ctx.atkinson1BitDitherer) { #ifdef ENABLE_IMAGE_DITHERING_EXTENSION switch (config.ditherMode) { case ImageDitherMode::Atkinson: diff --git a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp index e9aa0aad..53bbbea1 100644 --- a/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp +++ b/lib/Epub/Epub/converters/PngToFramebufferConverter.cpp @@ -1,8 +1,6 @@ #include "PngToFramebufferConverter.h" -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION #include -#endif #include #include #include @@ -39,6 +37,15 @@ struct PngContext { bool caching; uint8_t* grayLineBuffer; + + // When the caller requests monochrome output (RenderConfig::monochromeOutput), + // we run a proper 1-bit Atkinson dither (matching PngToBmpConverter's BW path) + // and emit only values 0 or 3, which round-trip cleanly through the BW writer's + // `pixelValue < 3` rule. The 4-level dither path collapses mid-grays to solid + // black under that rule. + int oneBitDitherRow; + Atkinson1BitDitherer* atkinson1BitDitherer; + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION int currentDitherRow; AtkinsonDitherer* atkinsonDitherer; @@ -57,7 +64,9 @@ struct PngContext { dstHeight(0), lastDstY(-1), caching(false), - grayLineBuffer(nullptr) + grayLineBuffer(nullptr), + oneBitDitherRow(-1), + atkinson1BitDitherer(nullptr) #ifdef ENABLE_IMAGE_DITHERING_EXTENSION , currentDitherRow(-1), @@ -67,14 +76,33 @@ struct PngContext { { } -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION ~PngContext() { + delete atkinson1BitDitherer; +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION delete atkinsonDitherer; delete diffusedBayerDitherer; - } #endif + } }; +// Advance the 1-bit Atkinson ditherer to the requested destination row. +// Like the 4-level path below, this handles re-decode passes that walk source +// rows non-monotonically by resetting and replaying when needed. +void prepareOneBitDitherRow(PngContext& ctx, int dstY) { + if (!ctx.atkinson1BitDitherer) return; + + if (ctx.oneBitDitherRow == -1 || dstY < ctx.oneBitDitherRow) { + ctx.atkinson1BitDitherer->reset(); + ctx.oneBitDitherRow = dstY; + return; + } + + while (ctx.oneBitDitherRow < dstY) { + ctx.atkinson1BitDitherer->nextRow(); + ctx.oneBitDitherRow++; + } +} + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION void prepareDitherRow(PngContext& ctx, int dstY) { if (!ctx.config || !ctx.config->useDithering) return; @@ -94,6 +122,12 @@ void prepareDitherRow(PngContext& ctx, int dstY) { } uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) { + // BW mode: route through 1-bit Atkinson and emit only 0/3 so the + // DirectPixelWriter's `pixelValue < 3` rule maps cleanly to black/white. + if (ctx.atkinson1BitDitherer) { + return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0; + } + if (!ctx.config || !ctx.config->useDithering) { return quantizeGray4Level(gray); } @@ -119,7 +153,9 @@ uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY } #else uint8_t ditherGray(PngContext& ctx, uint8_t gray, int localX, int outX, int outY) { - (void)ctx; + if (ctx.atkinson1BitDitherer) { + return ctx.atkinson1BitDitherer->processPixel(gray, localX) ? 3 : 0; + } (void)localX; return applyBayerDither4Level(gray, outX, outY); } @@ -291,6 +327,7 @@ int pngDrawCallback(PNGDRAW* pDraw) { cw.beginRow(outY, ctx->config->y); } + prepareOneBitDitherRow(*ctx, dstY); #ifdef ENABLE_IMAGE_DITHERING_EXTENSION prepareDitherRow(*ctx, dstY); #endif @@ -455,7 +492,20 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath } } - if (config.useDithering) { + // When the caller explicitly requests monochrome output, use a 1-bit Atkinson + // ditherer instead of the 4-level paths below. The 4-level dither produces + // values 1-2 for mid grays, which DirectPixelWriter then collapses to black + // under its `< 3` BW rule, making images render very dark in BW-only mode. + // The 1-bit ditherer emits only 0 or 3 so the BW writer maps cleanly to + // black/white. + if (config.monochromeOutput) { + ctx.atkinson1BitDitherer = new (std::nothrow) Atkinson1BitDitherer(ctx.dstWidth); + if (!ctx.atkinson1BitDitherer) { + LOG_ERR("PNG", "Failed to allocate 1-bit Atkinson ditherer, falling back to 4-level dither"); + } + } + + if (config.useDithering && !ctx.atkinson1BitDitherer) { #ifdef ENABLE_IMAGE_DITHERING_EXTENSION switch (config.ditherMode) { case ImageDitherMode::Atkinson: diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 4edefd80..80ad7e79 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -458,5 +458,7 @@ STR_COULD_NOT_RENDER_IMAGE: "Could not render image" STR_FAILED_TO_SET_SLEEP_SCREEN: "Failed to set sleep screen" STR_SET_SLEEP_SCREEN: "Set Sleep" STR_SLEEP_SCREEN_SET: "Sleep screen updated!" +STR_IMAGE_DISPLAY_BW: ">> B&W" +STR_IMAGE_DISPLAY_GRAYSCALE: ">> Gray" STR_WEATHER_MOON_INFO: "Moon" STR_WEATHER_SUN_INFO: "Sun" \ No newline at end of file diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index c7aced33..97ffb71a 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -336,6 +336,8 @@ STR_COULD_NOT_RENDER_IMAGE: "Bild konnte nicht dargestellt werden" STR_FAILED_TO_SET_SLEEP_SCREEN: "Standby-Bild konnte nicht gesetzt werden" STR_SET_SLEEP_SCREEN: "Standby-Bild setzen" STR_SLEEP_SCREEN_SET: "Standby-Bild aktualisiert!" +STR_IMAGE_DISPLAY_BW: ">> S/W" +STR_IMAGE_DISPLAY_GRAYSCALE: ">> Grau" STR_WEATHER: "Wetter" STR_WEATHER_LOCATION: "Ort" diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index f1ee331d..37f7ecc2 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -1,6 +1,7 @@ #include "SleepActivity.h" #include +#include #include #include #include @@ -9,6 +10,7 @@ #include #include #include +#include #include #include @@ -70,6 +72,127 @@ int32_t pngSleepSeek(PNGFILE* pFile, int32_t pos) { return f->seek(pos); } +bool renderPngSleepScreen(const std::string& filename, GfxRenderer& renderer, const BookOverlayInfo& overlayInfo) { + constexpr size_t MIN_FREE_HEAP = 60 * 1024; // PNG decoder ~42 KB + overhead + if (ESP.getFreeHeap() < MIN_FREE_HEAP) { + LOG_ERR("SLP", "Not enough heap for PNG sleep image: %s", filename.c_str()); + return false; + } + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + RenderConfig config; + config.x = 0; + config.y = 0; + config.maxWidth = pageWidth; + config.maxHeight = pageHeight; + config.useGrayscale = true; + config.useDithering = true; + config.ditherMode = ImageDitherMode::Bayer; + config.performanceMode = false; + config.useExactDimensions = false; + + // Overlay drawing is shared across all three rendering passes (BW + LSB + MSB) so the + // text appears on every plane. Captured by reference so the lambda sees the renderer. + const auto drawOverlay = [&]() { + if (overlayInfo.progressText.empty()) { + return; + } + const int lineHeight12 = renderer.getLineHeight(BOOKERLY_12_FONT_ID); + const int lineHeight10 = renderer.getLineHeight(UI_10_FONT_ID); + constexpr int lineSpacing = 3; + constexpr int sectionSpacing = 10; + const int maxTextWidth = pageWidth - 20; + + int textBlockHeight = 0; + if (!overlayInfo.title.empty()) { + textBlockHeight += lineHeight12; + if (!overlayInfo.author.empty()) { + textBlockHeight += lineSpacing; + } else if (!overlayInfo.progressText.empty()) { + textBlockHeight += sectionSpacing; + } + } + if (!overlayInfo.author.empty()) { + textBlockHeight += lineHeight10; + if (!overlayInfo.progressText.empty()) { + textBlockHeight += sectionSpacing; + } + } + if (!overlayInfo.progressText.empty()) { + textBlockHeight += lineHeight10; + } + + const int overlayY = pageHeight - textBlockHeight - (lineHeight12 / 3) - (lineHeight10 * 2 / 3); + int y = overlayY + (lineHeight12 / 3); + if (!overlayInfo.title.empty()) { + const std::string title = renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), maxTextWidth); + renderer.drawText(BOOKERLY_12_FONT_ID, 10, y, title.c_str(), true); + y += lineHeight12; + if (!overlayInfo.author.empty()) { + y += lineSpacing; + } else if (!overlayInfo.progressText.empty()) { + y += sectionSpacing; + } + } + if (!overlayInfo.author.empty()) { + const std::string author = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.author.c_str(), maxTextWidth); + renderer.drawText(UI_10_FONT_ID, 10, y, author.c_str(), true); + y += lineHeight10; + if (!overlayInfo.progressText.empty()) { + y += sectionSpacing; + } + } + if (!overlayInfo.progressText.empty()) { + const std::string progress = + renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), maxTextWidth); + renderer.drawText(UI_10_FONT_ID, 10, y, progress.c_str(), true); + } + }; + + PngToFramebufferConverter decoder; + + // Pass 1: BW plane — mirrors SleepActivity::renderBitmapSleepScreen so the BW carrier + // matches the 4-level quantization layered on top via the LSB/MSB planes. + renderer.setRenderMode(GfxRenderer::BW); + renderer.clearScreen(); + if (!decoder.decodeToFramebuffer(filename, renderer, config)) { + LOG_DBG("SLP", "PNG sleep image decode failed: %s", filename.c_str()); + return false; + } + drawOverlay(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + + // Pass 2: GRAYSCALE_LSB plane. + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + if (!decoder.decodeToFramebuffer(filename, renderer, config)) { + LOG_DBG("SLP", "PNG sleep image LSB decode failed: %s", filename.c_str()); + renderer.setRenderMode(GfxRenderer::BW); + return false; + } + drawOverlay(); + renderer.copyGrayscaleLsbBuffers(); + + // Pass 3: GRAYSCALE_MSB plane. + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + if (!decoder.decodeToFramebuffer(filename, renderer, config)) { + LOG_DBG("SLP", "PNG sleep image MSB decode failed: %s", filename.c_str()); + renderer.setRenderMode(GfxRenderer::BW); + return false; + } + drawOverlay(); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + return true; +} + // Per-scanline draw callback for PNG overlay compositing. // Transparent pixels (alpha < 128) are skipped so the reader page shows through. // Opaque pixels are drawn in their grayscale brightness (dark → black, light → white). @@ -216,9 +339,9 @@ size_t pickSleepImageIndex(size_t numFiles) { if (last == SIZE_MAX || last >= numFiles) return 0; return (last + 1) % numFiles; } - size_t idx = random(numFiles); + size_t idx = static_cast(esp_random() % numFiles); while (numFiles > 1 && APP_STATE.lastSleepImage != SIZE_MAX && idx == APP_STATE.lastSleepImage) { - idx = random(numFiles); + idx = static_cast(esp_random() % numFiles); } return idx; } @@ -266,28 +389,43 @@ void SleepActivity::renderCustomSleepScreen() const { } explicitSleepFile.close(); } + if (Storage.openFileForRead("SLP", "/sleep.png", explicitSleepFile)) { + explicitSleepFile.close(); + const BookOverlayInfo resolvedOverlayInfo = + shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo; + LOG_DBG("SLP", "Loading explicit custom sleep image: /sleep.png"); + if (renderPngSleepScreen("/sleep.png", renderer, resolvedOverlayInfo)) { + return; + } + } - // Collect valid BMP files from both /.sleep and /sleep directories (no preference between them) - const auto files = collectSleepImages(/*allowPng=*/false); + // Collect valid BMP and PNG files from both /.sleep and /sleep directories (no preference between them) + const auto files = collectSleepImages(/*allowPng=*/true); const auto numFiles = files.size(); if (numFiles > 0) { const auto pickedIndex = pickSleepImageIndex(numFiles); APP_STATE.lastSleepImage = pickedIndex; APP_STATE.saveToFile(); const auto& filename = files[pickedIndex]; - FsFile file; - if (Storage.openFileForRead("SLP", filename, file)) { - LOG_DBG("SLP", "Loading sleep image: %s", filename.c_str()); - delay(100); - Bitmap bitmap(file, true); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - const BookOverlayInfo resolvedOverlayInfo = - shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo; - renderBitmapSleepScreen(bitmap, resolvedOverlayInfo); - file.close(); + LOG_DBG("SLP", "Loading sleep image: %s", filename.c_str()); + const BookOverlayInfo resolvedOverlayInfo = + shouldLoadOverlayInfo ? getBookOverlayInfo(APP_STATE.openEpubPath) : overlayInfo; + if (FsHelpers::hasPngExtension(filename)) { + if (renderPngSleepScreen(filename, renderer, resolvedOverlayInfo)) { return; } - file.close(); + } else { + FsFile file; + if (Storage.openFileForRead("SLP", filename, file)) { + delay(100); + Bitmap bitmap(file, true); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderBitmapSleepScreen(bitmap, resolvedOverlayInfo); + file.close(); + return; + } + file.close(); + } } } @@ -487,7 +625,8 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver const bool hasTitle = !overlayInfo.title.empty(); const bool hasProgress = !overlayInfo.progressText.empty(); const bool hasAuthor = !overlayInfo.author.empty(); - if (!hasTitle && !hasAuthor && !hasProgress) { + // If there is no overlay progress text, do not draw the overlay background block. + if (!hasProgress) { return; } @@ -497,23 +636,13 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver constexpr int sectionSpacing = 10; const int availableWidth = pageWidth - 20; - int textBlockHeight = 0; + int textBlockHeight = lineHeight10; // progress line (always present here) if (hasTitle) { textBlockHeight += lineHeight12; - if (hasAuthor) { - textBlockHeight += lineSpacing; - } else if (hasProgress) { - textBlockHeight += sectionSpacing; - } + textBlockHeight += hasAuthor ? lineSpacing : sectionSpacing; } if (hasAuthor) { - textBlockHeight += lineHeight10; - if (hasProgress) { - textBlockHeight += sectionSpacing; - } - } - if (hasProgress) { - textBlockHeight += lineHeight10; + textBlockHeight += lineHeight10 + sectionSpacing; } const bool textBlack = (overlayMode != 3); @@ -534,8 +663,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver const std::string titleStr = renderer.truncatedText(BOOKERLY_12_FONT_ID, overlayInfo.title.c_str(), availableWidth, EpdFontFamily::BOLD); renderer.drawCenteredText(BOOKERLY_12_FONT_ID, currentY, titleStr.c_str(), textBlack, EpdFontFamily::BOLD); - const int spacingAfterTitle = hasAuthor ? lineSpacing : (hasProgress ? sectionSpacing : lineSpacing); - currentY += lineHeight12 + spacingAfterTitle; + currentY += lineHeight12 + (hasAuthor ? lineSpacing : sectionSpacing); } if (hasAuthor) { @@ -544,23 +672,20 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const BookOver currentY += lineHeight10 + sectionSpacing; } - if (hasProgress) { - std::string progressStr; - if (!overlayInfo.chapterName.empty()) { - const std::string prefix = ""; - const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str()); - const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str()); - const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth; - const std::string truncatedChapter = - maxChapterWidth > 0 - ? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth) - : ""; - progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix; - } else { - progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth); - } - renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack); + std::string progressStr; + if (!overlayInfo.chapterName.empty()) { + const std::string prefix = ""; + const int prefixWidth = renderer.getTextWidth(UI_10_FONT_ID, prefix.c_str()); + const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, overlayInfo.progressSuffix.c_str()); + const int maxChapterWidth = availableWidth - prefixWidth - suffixWidth; + const std::string truncatedChapter = + maxChapterWidth > 0 ? renderer.truncatedText(UI_10_FONT_ID, overlayInfo.chapterName.c_str(), maxChapterWidth) + : ""; + progressStr = prefix + truncatedChapter + overlayInfo.progressSuffix; + } else { + progressStr = renderer.truncatedText(UI_10_FONT_ID, overlayInfo.progressText.c_str(), availableWidth); } + renderer.drawCenteredText(UI_10_FONT_ID, currentY, progressStr.c_str(), textBlack); }; drawOverlay(); diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index 31eaa758..a5c478ea 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -150,14 +150,44 @@ bool BmpViewerActivity::renderBmpImage(const bool showControls) { GUI.fillPopupProgress(renderer, popupRect, 50); + bmpHasGreyscale = bitmap.hasGreyscale(); + // Only render in grayscale when the bitmap actually carries greyscale data AND the user has it enabled. + const bool renderGrayscale = bmpHasGreyscale && grayscaleDisplay; + + // Draw control hints. btn2 only shows the BW/Gray toggle when the bitmap supports greyscale — + // pure 1-bit BMPs have nothing to toggle. The label shows the *target* mode (what pressing it switches to). + const auto drawHints = [&]() { + if (!showControls) return; + const char* modeLabel = + bmpHasGreyscale ? (grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE)) : ""; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + }; + + renderer.setRenderMode(GfxRenderer::BW); renderer.clearScreen(); renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); - if (showControls) { - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - } + drawHints(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); + if (renderGrayscale) { + // Multi-pass 4-level grayscale render — mirrors SleepActivity::renderBitmapSleepScreen. + bitmap.rewindToData(); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); + renderer.copyGrayscaleLsbBuffers(); + + bitmap.rewindToData(); + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, 0, 0); + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); + } + file.close(); return true; } @@ -182,7 +212,6 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) { computeCenteredImagePlacement(dims.width, dims.height, pageWidth, pageHeight, x, y, renderWidth, renderHeight); GUI.fillPopupProgress(renderer, popupRect, 50); - renderer.clearScreen(); RenderConfig config{}; config.x = x; @@ -198,23 +227,78 @@ bool BmpViewerActivity::renderDecodedImage(const bool showControls) { config.ditherMode = ImageDitherMode::Bayer; #endif + // Helper to draw the on-screen control hints. The btn3 label shows the *other* mode + // (i.e. what pressing it would switch to). + const auto drawHints = [&]() { + if (!showControls) return; + const char* modeLabel = grayscaleDisplay ? tr(STR_IMAGE_DISPLAY_BW) : tr(STR_IMAGE_DISPLAY_GRAYSCALE); +#ifdef ENABLE_IMAGE_DITHERING_EXTENSION + const char* btn3Label = grayscaleDisplay ? I18N.get(getCurrentDitherModeLabel()) : modeLabel; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, btn3Label, tr(STR_SET_SLEEP_SCREEN)); +#else + const auto labels = mappedInput.mapLabels(tr(STR_BACK), modeLabel, "", tr(STR_SET_SLEEP_SCREEN)); +#endif + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + }; + + if (!grayscaleDisplay) { + // Pure black-and-white path: single decode with 1-bit Atkinson dither. + config.monochromeOutput = true; + renderer.setRenderMode(GfxRenderer::BW); + renderer.clearScreen(); + if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { + return false; + } + drawHints(); + renderer.displayBuffer(HalDisplay::HALF_REFRESH); + return true; + } + + // Grayscale path: three decode passes (BW + LSB + MSB), 4-level dither in all of them. + // Mirrors SleepActivity::renderCustomSleepScreen so the BW plane carries the same + // 4-level quantization as the gray planes layered on top. + config.monochromeOutput = false; + + renderer.setRenderMode(GfxRenderer::BW); + renderer.clearScreen(); if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { return false; } - - if (showControls) { -#ifdef ENABLE_IMAGE_DITHERING_EXTENSION - const auto labels = - mappedInput.mapLabels(tr(STR_BACK), "", I18N.get(getCurrentDitherModeLabel()), tr(STR_SET_SLEEP_SCREEN)); -#else - const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", tr(STR_SET_SLEEP_SCREEN)); -#endif - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - } + drawHints(); renderer.displayBuffer(HalDisplay::HALF_REFRESH); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { + renderer.setRenderMode(GfxRenderer::BW); + return false; + } + renderer.copyGrayscaleLsbBuffers(); + + renderer.clearScreen(0x00); + renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + if (!decoder->decodeToFramebuffer(filePath, renderer, config)) { + renderer.setRenderMode(GfxRenderer::BW); + return false; + } + renderer.copyGrayscaleMsbBuffers(); + + renderer.displayGrayBuffer(); + renderer.setRenderMode(GfxRenderer::BW); return true; } +void BmpViewerActivity::toggleDisplayMode() { + grayscaleDisplay = !grayscaleDisplay; + // Switching between 1-bit BW and 4-level grayscale requires a full refresh to clear + // ghosting from the previous mode — a half refresh leaves visible residue. + renderer.clearScreen(); + renderer.displayBuffer(HalDisplay::FULL_REFRESH); + if (!renderCurrentImage()) { + renderError(tr(STR_COULD_NOT_RENDER_IMAGE)); + } +} + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION StrId BmpViewerActivity::getCurrentDitherModeLabel() const { switch (imageDitherModeFromSetting(imageDitherMode)) { @@ -312,8 +396,17 @@ void BmpViewerActivity::loop() { return; } + // Confirm: toggle between 1-bit B&W and 4-level grayscale display. + // For decoded images this always applies; for BMPs it only makes sense when the bitmap + // actually carries greyscale data (1-bit BMPs have nothing to toggle). + const bool toggleSupported = isBmpFile(filePath) ? bmpHasGreyscale : true; + if (toggleSupported && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + toggleDisplayMode(); + return; + } + #ifdef ENABLE_IMAGE_DITHERING_EXTENSION - if (!isBmpFile(filePath) && mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (!isBmpFile(filePath) && grayscaleDisplay && mappedInput.wasReleased(MappedInputManager::Button::Left)) { cycleDitherMode(); return; } diff --git a/src/activities/util/BmpViewerActivity.h b/src/activities/util/BmpViewerActivity.h index 076835a6..7b238bdc 100644 --- a/src/activities/util/BmpViewerActivity.h +++ b/src/activities/util/BmpViewerActivity.h @@ -24,9 +24,16 @@ class BmpViewerActivity final : public Activity { uint8_t initialImageDitherMode; bool imageDitherSettingsDirty; #endif + // Per-session toggle: monochrome (1-bit Atkinson, single decode) vs grayscale (4-level dither, multipass). + // Not persisted — defaults to grayscale every time the viewer opens. + bool grayscaleDisplay = true; + // True after a successful BMP render iff the bitmap actually carries greyscale data. + // Used to gate the BW/Gray toggle in loop() — pure 1-bit BMPs cannot be toggled. + bool bmpHasGreyscale = false; bool renderCurrentImage(bool showControls = true); bool renderBmpImage(bool showControls = true); bool renderDecodedImage(bool showControls = true); + void toggleDisplayMode(); #ifdef ENABLE_IMAGE_DITHERING_EXTENSION void cycleDitherMode(); StrId getCurrentDitherModeLabel() const;