Files
roro9stack/src/apps/image_pane.cpp
T
twislaandClaude Opus 5.5 2c18762614
CI / build (pull_request) Successful in 1m56s
Site / build (pull_request) Successful in 10s
Storage: view pictures, PNG, JPEG, BMP and GIF (#45)
Enter on a picture shows it: shrunk to fit the screen, or at its own size
with Enter again and the arrows to move. Dithered to the screen's 256
colours; a colour the screen has exactly is left alone, so screenshots are
shown as they are.

The picture is decoded once, straight into the screen's buffer, and kept
there (App::retainsContent): no copy in memory. Decoding runs on the
storage task, so the keys keep working and a 12 megapixel photograph
appears as it comes instead of tripping the watchdog.

PNG, BMP and GIF are read by decoders of our own, host-tested against files
made by Pillow; the PNG one needs 32 KB where the display library's needed
44 KB in one block, which the device often doesn't have. JPEG uses the
library's TJpgDec.

Also corrects two sentences that still gave 16 KB as the editing limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 21:04:39 +02:00

349 lines
12 KiB
C++

#include "image_pane.h"
#include <Arduino.h>
#include <SD.h>
#include <atomic>
#include <cstring>
#include <memory>
#include <new>
#include <lgfx/utility/lgfx_tjpgd.h>
#include "app_keys.h"
#include "file_names.h"
#include "platform/console.h"
#include "png_reader.h"
#include "ui/fonts.h"
#include "ui/theme.h"
namespace roro {
namespace {
constexpr uint32_t kNoteMs = 3000;
constexpr uint32_t kPushMs = 250; // how often the screen shows how far the decoding is
constexpr int kNoteHeight = 11;
constexpr size_t kJpegPool = 3900; // what the library gives its own JPEG decoder
} // namespace
struct ImagePane::Job {
std::string path, why;
files::ImageInfo info;
files::ImageFrame frame;
files::ImageMap map;
uint32_t size = 0, shotAt = 0, tookMs = 0;
uint8_t* screen = nullptr; // the screen's buffer: one byte a pixel, RRRGGGBB
int stride = 0;
std::atomic<bool> stop{false}, done{false};
bool enough = false; // the rest of the file is under the view: the decoder is told to stop
File* file = nullptr;
uint32_t sinceRest = 0;
void put(int sx, int sy, uint8_t r, uint8_t g, uint8_t b) {
int tx, ty;
if (map.at(sx, sy, tx, ty)) screen[ty * stride + tx] = files::rgb332Dithered(r, g, b, tx, ty);
}
// A long decoding must leave the processor to others now and then (the idle task is watched).
void rest(uint32_t bytes) {
sinceRest += bytes;
if (sinceRest < 16 * 1024) return;
sinceRest = 0;
vTaskDelay(1);
}
size_t readAt(uint32_t at, uint8_t* into, size_t len) {
if (stop || !file->seek(at)) return 0;
int n = file->read(into, len);
rest(static_cast<uint32_t>(len));
return n > 0 ? static_cast<size_t>(n) : 0;
}
void run();
};
namespace {
// The library's JPEG decoder reads the file from its start on; a null buffer means "skip".
// Nothing more to read is how a decoding is told to stop.
uint32_t readNext(void* job, uint8_t* into, uint32_t len) {
auto& j = *static_cast<ImagePane::Job*>(job);
if (j.stop || j.enough) return 0;
File& f = *j.file;
j.rest(len);
if (!into) return f.seek(f.position() + len) ? len : 0;
int n = f.read(into, len);
return n > 0 ? static_cast<uint32_t>(n) : 0;
}
// A block of a JPEG: its pixels row by row, three bytes each.
uint32_t jpegBlock(void* job, void* bitmap, JRECT* rect) {
auto& j = *static_cast<ImagePane::Job*>(job);
const uint8_t* p = static_cast<const uint8_t*>(bitmap);
if (j.map.below(static_cast<int>(rect->top))) return j.enough = true, 0;
for (uint32_t y = rect->top; y <= rect->bottom; y++)
for (uint32_t x = rect->left; x <= rect->right; x++, p += 3) j.put(static_cast<int>(x), static_cast<int>(y), p[0], p[1], p[2]);
return j.stop ? 0 : 1;
}
} // namespace
// On the storage task.
void ImagePane::Job::run() {
uint32_t started = millis();
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
why = "The card refused to open it";
done = true;
return;
}
file = &f;
files::ImageRead read = [this](uint32_t at, uint8_t* into, size_t len) { return readAt(at, into, len); };
files::ImagePixels pixels = [this](int x, int y, int count, const uint8_t* rgb) {
for (int i = 0; i < count; i++, rgb += 3) put(x + i, y, rgb[0], rgb[1], rgb[2]);
};
switch (info.kind) {
case files::ImageKind::Png:
if (shotAt) { // one of ours: each byte is already a colour of the screen
std::unique_ptr<uint8_t[]> row(new (std::nothrow) uint8_t[info.width]);
if (!row) {
why = "Not enough memory";
break;
}
for (int y = 0; y < info.height && why.empty() && !stop; y++) {
if (!map.rowUsed(y)) continue;
size_t w = static_cast<size_t>(info.width);
if (readAt(shotAt + static_cast<uint32_t>(y) * (info.width + 1), row.get(), w) != w) why = "The card refused to read it";
int tx, ty;
for (int x = 0; x < info.width; x++)
if (map.at(x, y, tx, ty)) screen[ty * stride + tx] = row[x];
}
break;
}
why = files::readPng(read, size, pixels, [this](int y) { return map.rowUsed(y); }, [this](int y) { return map.below(y); });
break;
case files::ImageKind::Jpeg: {
std::unique_ptr<lgfxJdec> jpeg(new (std::nothrow) lgfxJdec);
std::unique_ptr<uint8_t[]> pool(new (std::nothrow) uint8_t[kJpegPool]);
if (!jpeg || !pool) {
why = "Not enough memory";
break;
}
int shrink = frame.jpegShrink();
map = frame.map(shrink);
JRESULT r = lgfx_jd_prepare(jpeg.get(), readNext, pool.get(), kJpegPool, this);
if (r == JDR_OK) r = lgfx_jd_decomp(jpeg.get(), jpegBlock, static_cast<uint_fast8_t>(shrink));
if (r == JDR_FMT3) why = "This kind of JPEG can't be shown";
else if (r == JDR_MEM1 || r == JDR_MEM2) why = "This JPEG is too complex to show";
else if (r != JDR_OK && !enough) why = "This JPEG is damaged";
break;
}
case files::ImageKind::Bmp:
why = files::readBmp(read, size, pixels, [this](int y) { return map.rowUsed(y); });
break;
case files::ImageKind::Gif: why = files::readGif(read, size, pixels); break;
default: why = "Not a picture this can show"; break;
}
f.close();
file = nullptr;
tookMs = millis() - started;
if (stop) why.clear();
else
console.printf("image: %s, %d x %d %s, %s in %u ms\n", path.c_str(), info.width, info.height, files::imageKindName(info.kind),
why.empty() ? (frame.actual() ? "its own size" : "fitted") : why.c_str(), (unsigned)tookMs);
done = true;
}
std::string ImagePane::open(const std::string& path, uint32_t size) {
close();
std::string why;
files::ImageInfo info;
uint32_t shotAt = 0;
bool ran = storage_.runAndWait([&]() {
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
why = "The card refused to open it";
return;
}
files::ImageRead read = [&f](uint32_t at, uint8_t* into, size_t len) -> size_t {
if (!f.seek(at)) return 0;
int n = f.read(into, len);
return n > 0 ? static_cast<size_t>(n) : 0;
};
why = files::imageInfo(read, size, info);
if (why.empty() && info.kind == files::ImageKind::Png) shotAt = files::screenshotPixelsAt(read, size, info.width, info.height);
f.close();
});
if (!ran) why = "No SD card";
if (!why.empty()) return why;
path_ = path;
size_ = size;
info_ = info;
shotAt_ = shotAt;
const auto& area = theme::kContent;
frame_ = files::ImageFrame(info.width, info.height, area.x, area.y, area.w, area.h);
phase_ = Phase::Wanted;
told_ = noteDrawn_ = false;
problem_.clear();
note_.clear();
return "";
}
void ImagePane::cancel() {
if (job_ && !job_->done) {
job_->stop = true;
storage_.runAndWait([]() {}); // behind the decoding in the queue: back when it has ended
}
job_.reset();
}
void ImagePane::close() {
cancel();
path_.clear();
problem_.clear();
note_.clear();
info_ = files::ImageInfo();
phase_ = Phase::Wanted;
std::vector<uint8_t>().swap(under_);
}
void ImagePane::lost() {
cancel();
phase_ = Phase::Wanted;
noteDrawn_ = false;
}
std::string ImagePane::describe() const {
std::string s = std::to_string(info_.width) + " x " + std::to_string(info_.height) + " " + files::imageKindName(info_.kind);
if (frame_.bigger()) s += frame_.actual() ? ", its own size" : ", at " + std::to_string(frame_.percent()) + " %";
return s;
}
void ImagePane::say(const std::string& text) {
hideNote(canvas_);
note_ = text;
noteMs_ = millis();
}
void ImagePane::help(std::vector<KeyHelp>& out) const { keys::add(out, keys::kViewerImage); }
bool ImagePane::onKey(const KeyEvent& e) {
if (path_.empty()) return false;
bool changed = false;
switch (e.key) {
case Key::Select:
if (!frame_.bigger()) return true;
cancel(); // before the frame it is drawing into changes
frame_.toggle();
told_ = false;
changed = true;
break;
case Key::Up:
case Key::Down:
case Key::Left:
case Key::Right: {
if (!frame_.actual()) return true;
cancel();
int dx = e.key == Key::Left ? -1 : e.key == Key::Right ? 1 : 0, dy = e.key == Key::Up ? -1 : e.key == Key::Down ? 1 : 0;
changed = frame_.pan(dx, dy);
if (!changed && phase_ == Phase::Decoding) changed = true; // it was stopped: start it again
break;
}
case Key::Char:
if (e.ch != 'i' && e.ch != 'I') return false;
if (phase_ == Phase::Shown) say(describe());
return true;
default: return false;
}
if (changed) {
phase_ = Phase::Wanted;
note_.clear();
noteDrawn_ = false;
}
return true;
}
// The strip the note was written over goes back as it was: no decoding for that.
void ImagePane::hideNote(Canvas* c) {
if (noteDrawn_ && c && phase_ == Phase::Shown && under_.size() == static_cast<size_t>(c->width()) * kNoteHeight) {
const auto& area = theme::kContent;
uint8_t* screen = static_cast<uint8_t*>(c->getBuffer());
std::memcpy(screen + (area.y + area.h - kNoteHeight) * c->width(), under_.data(), under_.size());
}
noteDrawn_ = false;
note_.clear();
}
bool ImagePane::update(uint32_t) {
if (path_.empty()) return false;
uint32_t now = millis();
if (phase_ == Phase::Wanted) return true;
if (phase_ == Phase::Decoding) {
if (job_ && job_->done) return true;
if (now - pushedMs_ < kPushMs) return false;
pushedMs_ = now;
return true; // what has arrived so far
}
if (!note_.empty() && now - noteMs_ >= kNoteMs) {
hideNote(canvas_);
return true;
}
return !note_.empty() && !noteDrawn_;
}
void ImagePane::start(Canvas& c) {
cancel();
job_ = std::make_shared<Job>();
job_->path = path_;
job_->info = info_;
job_->frame = frame_;
job_->map = frame_.map();
job_->size = size_;
job_->shotAt = shotAt_;
job_->screen = static_cast<uint8_t*>(c.getBuffer());
job_->stride = c.width();
auto job = job_;
storage_.runJob([job]() { job->run(); });
phase_ = Phase::Decoding;
pushedMs_ = millis();
}
void ImagePane::draw(Canvas& c) {
if (path_.empty()) return;
canvas_ = &c;
const auto& area = theme::kContent;
if (phase_ == Phase::Wanted) {
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
noteDrawn_ = false;
problem_.clear();
start(c);
return;
}
if (phase_ == Phase::Decoding) {
if (!job_ || !job_->done) return; // the picture is arriving in the buffer by itself
problem_ = job_->why;
job_.reset();
phase_ = Phase::Shown;
if (!problem_.empty()) {
c.fillRect(area.x, area.y, area.w, area.h, theme::kBackground);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.setTextDatum(middle_center);
c.drawString(problem_.c_str(), area.x + area.w / 2, area.y + area.h / 2);
c.setTextDatum(top_left);
} else if (!told_) {
told_ = true;
note_ = describe();
noteMs_ = millis();
}
}
if (!note_.empty() && !noteDrawn_) {
int top = area.y + area.h - kNoteHeight;
uint8_t* screen = static_cast<uint8_t*>(c.getBuffer());
under_.assign(screen + top * c.width(), screen + (top + kNoteHeight) * c.width());
c.fillRect(area.x, top, area.w, kNoteHeight, theme::kBackground);
c.setFont(&fonts::small);
c.setTextColor(theme::kText);
c.setTextDatum(top_left);
c.drawString(note_.c_str(), area.x + 4, top + 2);
noteDrawn_ = true;
}
}
} // namespace roro