Storage: view pictures, PNG, JPEG, BMP and GIF (#45)
CI / build (pull_request) Successful in 1m56s
Site / build (pull_request) Successful in 10s

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
This commit is contained in:
2026-10-07 21:04:39 +02:00
co-authored by Claude Opus 5.5
parent ae25cf0be2
commit 2c18762614
22 changed files with 2074 additions and 9 deletions
+15 -2
View File
@@ -162,11 +162,15 @@ void FileViewer::open(const std::string& path, uint32_t size) {
file_ = std::make_shared<fs::File>();
std::string name = files::baseName(path);
files::FileKind kind = files::kindOf(name);
if (kind == files::FileKind::Unknown) { // what do its first bytes look like?
files::ImageKind picture = files::imageKindOfName(name);
if (kind == files::FileKind::Unknown && picture == files::ImageKind::None) { // what do its first bytes look like?
uint8_t head[256];
size_t n = readAt(0, head, sizeof head);
picture = files::imageKindOfBytes(head, n);
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
}
std::string notShown;
if (picture != files::ImageKind::None) notShown = image_.open(path, size);
switch (kind) {
case files::FileKind::Text: base_ = Mode::Text; break;
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
@@ -174,11 +178,13 @@ void FileViewer::open(const std::string& path, uint32_t size) {
case files::FileKind::Ota: base_ = Mode::Ota; break;
default: base_ = Mode::Hex; break;
}
if (picture != files::ImageKind::None && notShown.empty()) base_ = Mode::Image;
pager_.reset(new files::TextPager([this](uint32_t offset, uint8_t* into, size_t len) { return readAt(offset, into, len); }, size_,
kCols, kRows));
if (files::opensAtEnd(name)) pager_->toEnd();
hexTop_ = 0;
show(base_);
if (!notShown.empty()) say(notShown); // a picture that can't be shown: its bytes, and why
if (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
}
@@ -203,6 +209,7 @@ void FileViewer::close() {
});
}
file_.reset();
image_.close();
pager_.reset();
confirm_.reset();
message_.clear();
@@ -214,11 +221,13 @@ void FileViewer::close() {
void FileViewer::show(Mode mode) {
mode_ = mode;
if (mode == Mode::Image) image_.lost(); // another view was drawn where it was
stale_ = true;
rowsFrom_ = -1;
}
void FileViewer::say(const std::string& text) {
if (mode_ == Mode::Image) return image_.say(text);
message_ = text;
messageMs_ = millis();
}
@@ -275,6 +284,7 @@ void FileViewer::help(std::vector<KeyHelp>& out) const {
case Mode::Packet: keys::add(out, keys::kViewerPacket); break;
case Mode::Gpx: keys::add(out, keys::kViewerGpx); break;
case Mode::Ota: keys::add(out, keys::kViewerOta); break;
case Mode::Image: image_.help(out); break;
}
}
@@ -299,6 +309,7 @@ bool FileViewer::onKey(const KeyEvent& e) {
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
return true;
}
if (mode_ == Mode::Image) return image_.onKey(e), true;
uint32_t ch = e.key == Key::Char ? e.ch : 0;
switch (mode_) {
case Mode::Text:
@@ -332,7 +343,8 @@ bool FileViewer::onKey(const KeyEvent& e) {
return true;
}
bool FileViewer::update(uint32_t) {
bool FileViewer::update(uint32_t nowMs) {
if (mode_ == Mode::Image) return image_.update(nowMs);
if (!message_.empty() && millis() - messageMs_ >= 4000) {
message_.clear();
return true;
@@ -389,6 +401,7 @@ void FileViewer::refresh() {
}
void FileViewer::draw(Canvas& c) {
if (mode_ == Mode::Image) return image_.draw(c);
const auto& area = theme::kContent;
refresh();
c.setTextDatum(top_left);
+7 -2
View File
@@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include "apps/image_pane.h"
#include "dialog_model.h"
#include "key_help.h"
#include "key_event.h"
@@ -23,7 +24,7 @@ namespace roro {
// genuine. Tab switches to the text or the hex of the same file. Nothing here changes a file.
class FileViewer {
public:
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update) {}
FileViewer(StorageService& storage, UpdateService& update) : storage_(storage), update_(update), image_(storage) {}
void open(const std::string& path, uint32_t size);
void close();
@@ -37,9 +38,12 @@ class FileViewer {
const std::string& path() const { return path_; }
uint32_t size() const { return size_; }
void say(const std::string& text);
// A picture is drawn once and kept on the screen (App::retainsContent).
bool retains() const { return mode_ == Mode::Image; }
void lost() { image_.lost(); }
private:
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota, Image };
static constexpr int kRows = 8;
static constexpr int kCols = 38;
struct Scan; // what a storage job reads through a whole file for: shared with that job
@@ -54,6 +58,7 @@ class FileViewer {
StorageService& storage_;
UpdateService& update_;
ImagePane image_;
std::string path_;
uint32_t size_ = 0;
Mode mode_ = Mode::Text, base_ = Mode::Text;
+348
View File
@@ -0,0 +1,348 @@
#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
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "image_file.h"
#include "key_event.h"
#include "key_help.h"
#include "services/storage_service.h"
#include "ui/canvas.h"
namespace roro {
// A picture from the card, for the Storage App's viewer (issue #45, F1 Q233-Q242): PNG, JPEG, BMP
// and the first picture of a GIF (only JPEG needs a decoder that isn't ours). It is decoded once, straight into the screen's own buffer, and
// left there (App::retainsContent): there is no copy of it in memory. It is decoded again only
// when something else was drawn over it, or when it is zoomed or moved.
//
// The decoding runs on the storage task while the main loop goes on: a photograph takes seconds,
// and the picture appears as it comes. Anything that needs the screen back stops it first.
class ImagePane {
public:
explicit ImagePane(StorageService& storage) : storage_(storage) {}
std::string open(const std::string& path, uint32_t size); // "" or why it can't be shown
void close();
bool onKey(const KeyEvent& e); // true: the key was the picture's
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
void lost(); // the screen no longer holds it
void say(const std::string& text);
struct Job; // one decoding: shared with the storage task, which may outlive the view of it
private:
enum class Phase { Wanted, Decoding, Shown };
std::string describe() const;
void start(Canvas& c);
void cancel(); // returns once the storage task has let go of the screen
void hideNote(Canvas* c);
StorageService& storage_;
std::string path_, problem_, note_;
uint32_t size_ = 0, noteMs_ = 0, shotAt_ = 0, pushedMs_ = 0;
files::ImageInfo info_;
files::ImageFrame frame_;
Phase phase_ = Phase::Wanted;
std::shared_ptr<Job> job_;
Canvas* canvas_ = nullptr;
bool told_ = false, noteDrawn_ = false;
std::vector<uint8_t> under_; // what the note was written over
};
} // namespace roro
+2
View File
@@ -34,6 +34,8 @@ class StorageApp : public App {
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
bool retainsContent() const override { return view_ == View::Viewer && viewer_.retains(); }
void contentLost() override { viewer_.lost(); }
void help(std::vector<KeyHelp>& out) const override;
const char* helpTitle() const override;
+16 -2
View File
@@ -13,10 +13,23 @@ bool Screen::begin() {
}
void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) {
canvas_.fillSprite(theme::kBackground);
const auto& area = theme::kContent;
App& app = apps.foreground();
// An App that keeps what it drew: only the Status Bar is cleared, unless the App has changed
// or a Toast or the help panel, drawn over it, has just gone.
bool overlay = apps.help().isOpen() || toast, retains = app.retainsContent();
if (overlayWas_ && !overlay) lost_ = true;
if (retains && &app == lastApp_ && !lost_) {
canvas_.fillRect(0, 0, theme::kWidth, area.y, theme::kBackground);
} else {
canvas_.fillSprite(theme::kBackground);
if (retains) app.contentLost();
}
lastApp_ = &app;
lost_ = false;
overlayWas_ = overlay && retains;
canvas_.setClipRect(area.x, area.y, area.w, area.h);
apps.foreground().draw(canvas_);
app.draw(canvas_);
if (apps.help().isOpen()) widgets::help(canvas_, apps.help()); // over the App, under the Status Bar
canvas_.clearClipRect();
widgets::statusBar(canvas_, status);
@@ -25,6 +38,7 @@ void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toa
}
void Screen::renderUpdate(const std::string& title, const std::string& detail, int percent) {
lost_ = true;
canvas_.fillSprite(theme::kBackground);
canvas_.setTextDatum(top_center);
canvas_.setFont(&fonts::bold);
+2
View File
@@ -17,6 +17,8 @@ class Screen {
private:
Canvas canvas_;
const App* lastApp_ = nullptr; // for an App that keeps what it drew (App::retainsContent)
bool lost_ = true, overlayWas_ = false;
};
} // namespace roro