F1 steps 2-6: the Storage App, its viewers, and Maintenance moved in (#3)

The Storage App browses the SD card: folders first with sizes and dates,
three sorts, one item at a time with a clipboard (c, x, v), rename, delete
after counting what's inside, new folder, details. A listing holds 256
entries and says when a folder has more.

FileOps does the card's work for the App and the console alike, one
operation at a time on the storage task in turns of about 150 ms, so Logs
and Captures are still written during a long copy. A copy shows progress,
can be cancelled (what it wrote is taken back) and compares sizes after.
The read-only rules are checked there: the firmware's top-level folders,
/gemini/cache, and files being written (a Track, a Capture, an upload,
today's IRC Logs). A listing reads the folder straight from FatFs: through
the Arduino File, 329 entries took over two seconds.

Viewers by type: text read a screen at a time whatever the file's size
(logs open at the end), a hex dump, a Capture's packets as the LoRa Scanner
lists them, a Track's summary, and an Update File checked as an install
would check it, without writing anything. Tab shows any file as hex or text.

Settings > Storage is gone: usage, Storage Clean-up and Erase are the App's
Maintenance, behind a warning. The Storage Warning points there.

The Clock sets the system time whatever its source, so files are dated
correctly with a GNSS Fix alone (Q137).

Console: cp, mv, mkdir, du, cancel; rm takes folders and follows the rules;
ls shows dates; Debug Builds get `sd fill`.

424 host tests. Checked on the device: docs/milestones/F1.md.

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-06 08:45:43 +02:00
co-authored by Claude Opus 5.5
parent 7ab8f043d0
commit b7aed8e91c
28 changed files with 2660 additions and 41 deletions
+458
View File
@@ -0,0 +1,458 @@
#include "file_viewer.h"
#include <Arduino.h>
#include <SD.h>
#include <algorithm>
#include <atomic>
#include <cstdio>
#include <ctime>
#include "cleanup_plan.h"
#include "file_list.h"
#include "file_names.h"
#include "file_views.h"
#include "meshtastic_presets.h"
#include "packet_view.h"
#include "platform/ota_device.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
#include "update_parser.h"
#include "version.h"
namespace roro {
namespace {
constexpr uint32_t kSliceMs = 150; // like the Storage App's operations: the Logs get their turn
constexpr size_t kPiece = 1024;
constexpr size_t kMaxPackets = 1000; // their places in the file, 4 KB
constexpr size_t kMaxPacketBytes = 271; // LoRaTap and the longest LoRa packet
// Checks an Update File as an install would, writing nothing.
struct NoSink : UpdateSink {
bool begin(size_t) override { return true; }
bool write(const uint8_t*, size_t) override { return true; }
bool finish() override { return true; }
void abort() override {}
};
std::string clockTime(uint32_t utcSeconds) {
time_t t = static_cast<time_t>(utcSeconds);
struct tm local;
localtime_r(&t, &local);
char s[12];
std::snprintf(s, sizeof s, "%02d:%02d:%02d", local.tm_hour, local.tm_min, local.tm_sec);
return s;
}
} // namespace
struct FileViewer::Scan {
std::atomic<bool> done{false}, stop{false};
std::atomic<int> percent{0};
Mode what = Mode::Gpx;
std::string path;
// Touched by the storage task until `done`, by the viewer after.
fs::File file;
bool opened = false;
uint32_t at = 0, size = 0;
std::string error;
files::GpxSummary gpx;
files::PcapHeader pcap;
std::vector<uint32_t> packets; // where each record starts
bool morePackets = false;
std::unique_ptr<EcdsaVerifier> verifier;
NoSink sink;
std::unique_ptr<UpdateParser> parser;
std::string version;
bool genuine = false, older = false;
bool slice(); // true when there's nothing more to read
static void run(StorageService* storage, std::shared_ptr<Scan> self) {
bool over = self->stop || self->slice();
if (over) {
if (self->file) self->file.close();
self->parser.reset();
self->verifier.reset();
self->done = true;
} else {
storage->runJob([storage, self]() { run(storage, self); });
}
}
};
bool FileViewer::Scan::slice() {
uint32_t t0 = millis();
if (!opened) {
opened = true;
file = SD.open(path.c_str(), FILE_READ);
if (!file) {
error = "The card refused to open it";
return true;
}
size = static_cast<uint32_t>(file.size());
if (what == Mode::Ota) {
verifier.reset(new EcdsaVerifier());
parser.reset(new UpdateParser(*verifier, sink, EspOtaSink().capacity(), versionString()));
}
}
std::unique_ptr<uint8_t[]> piece(new uint8_t[kPiece]);
while (millis() - t0 < kSliceMs) {
if (stop) return true;
if (what == Mode::Pcap) {
if (at == 0) {
int n = file.read(piece.get(), files::kPcapHeaderSize);
pcap = files::parsePcapHeader(piece.get(), n < 0 ? 0 : n);
if (!pcap.ok) {
error = "Not a pcap file this viewer reads";
return true;
}
at = files::kPcapHeaderSize;
}
files::PcapRecord r;
if (!file.seek(at)) return true;
int n = file.read(piece.get(), files::kPcapRecordSize);
if (n < 0 || !files::parsePcapRecord(piece.get(), n, r) || at + files::kPcapRecordSize + r.length > size) return true;
if (packets.size() >= kMaxPackets) {
morePackets = true;
return true;
}
packets.push_back(at);
at += files::kPcapRecordSize + r.length;
continue;
}
int n = file.read(piece.get(), kPiece);
if (n <= 0) break;
at += n;
percent = size ? static_cast<int>(static_cast<uint64_t>(at) * 100 / size) : 100;
if (what == Mode::Gpx) {
gpx.feed(reinterpret_cast<const char*>(piece.get()), n);
} else {
parser->feed(piece.get(), n);
if (parser->state() == UpdateParser::State::Failed) break;
}
}
if (at < size && !(parser && parser->state() == UpdateParser::State::Failed) && millis() - t0 >= kSliceMs) return false;
if (parser) {
genuine = parser->end();
version = parser->version();
older = parser->isDowngrade();
error = parser->error();
}
return true;
}
size_t FileViewer::readAt(uint32_t offset, uint8_t* into, size_t len) {
size_t got = 0;
auto file = file_;
std::string path = path_;
storage_.runAndWait([&, file, path]() {
if (!*file) *file = SD.open(path.c_str(), FILE_READ);
if (!*file || !file->seek(offset)) return;
int n = file->read(into, len);
got = n < 0 ? 0 : n;
});
return got;
}
void FileViewer::open(const std::string& path, uint32_t size) {
close();
path_ = path;
size_ = 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?
uint8_t head[256];
size_t n = readAt(0, head, sizeof head);
kind = files::looksLikeText(head, n) ? files::FileKind::Text : files::FileKind::Unknown;
}
switch (kind) {
case files::FileKind::Text: base_ = Mode::Text; break;
case files::FileKind::Gpx: base_ = Mode::Gpx; break;
case files::FileKind::Pcap: base_ = Mode::Pcap; break;
case files::FileKind::Ota: base_ = Mode::Ota; break;
default: base_ = Mode::Hex; break;
}
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 (base_ == Mode::Gpx || base_ == Mode::Pcap || base_ == Mode::Ota) startScan(base_);
}
void FileViewer::startScan(Mode mode) {
scan_ = std::make_shared<Scan>();
scan_->what = mode;
scan_->path = path_;
scanShown_ = false;
shownPercent_ = -1;
auto scan = scan_;
StorageService* storage = &storage_;
storage_.runJob([storage, scan]() { Scan::run(storage, scan); });
}
void FileViewer::close() {
if (scan_) scan_->stop = true; // its job ends at the next piece and frees itself
scan_.reset();
if (file_) {
auto file = file_;
storage_.runJob([file]() {
if (*file) file->close();
});
}
file_.reset();
pager_.reset();
confirm_.reset();
std::vector<std::string>().swap(shown_);
std::vector<std::string>().swap(details_);
packets_.setCount(0);
rowsFrom_ = -1;
}
void FileViewer::show(Mode mode) {
mode_ = mode;
stale_ = true;
rowsFrom_ = -1;
}
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
void FileViewer::scroll(int lines) {
if (mode_ == Mode::Text && pager_) {
if (lines > 0) pager_->down(lines);
else pager_->up(-lines);
} else if (mode_ == Mode::Hex) {
uint32_t rows = (size_ + 7) / 8, last = rows > static_cast<uint32_t>(kRows) ? rows - kRows : 0;
int64_t to = static_cast<int64_t>(hexTop_) + lines;
hexTop_ = static_cast<uint32_t>(std::clamp<int64_t>(to, 0, last));
}
stale_ = true;
}
void FileViewer::openPacket(int index) {
if (!scan_ || !scan_->done || index < 0 || index >= static_cast<int>(scan_->packets.size())) return;
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
size_t n = readAt(scan_->packets[index], buf, sizeof buf);
files::PcapRecord r;
if (!files::parsePcapRecord(buf, n, r)) return;
const uint8_t* body = buf + files::kPcapRecordSize;
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
details_.clear();
char line[64];
lora::RxInfo rx;
bool tap = scan_->pcap.linkType == files::kLinkLoraTap && files::parseLoraTap(body, have, rx);
size_t skip = tap ? lora::kLoraTapSize : 0;
std::snprintf(line, sizeof line, "%s, %u bytes", clockTime(r.seconds).c_str(), static_cast<unsigned>(r.length - skip));
details_.push_back(line);
if (tap) {
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
details_.push_back(line);
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
details_.push_back(line);
if (rx.syncWord == meshtastic::kSyncWord)
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
}
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
if (have < r.length) details_.push_back("(the first bytes only)");
detailsTop_ = 0;
mode_ = Mode::Packet;
}
bool FileViewer::onKey(const KeyEvent& e) {
if (confirm_) {
confirm_->onKey(e);
if (confirm_->result() == 1) update_.installFromSd(path_);
if (confirm_->result() != DialogModel::kPending) confirm_.reset();
return true;
}
if (mode_ == Mode::Packet) {
int last = std::max(0, static_cast<int>(details_.size()) - (kRows + 1));
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
else if (e.key == Key::Down) detailsTop_ = std::min(last, detailsTop_ + 1);
else if (e.key == Key::Back || e.key == Key::Select) mode_ = Mode::Pcap;
return true;
}
if (e.key == Key::Back) return false;
if (e.key == Key::Tab) {
// The file's own view, or what it's made of: text for a Track, bytes for the others.
if (mode_ != base_) show(base_);
else show(base_ == Mode::Hex || base_ == Mode::Gpx ? Mode::Text : Mode::Hex);
return true;
}
uint32_t ch = e.key == Key::Char ? e.ch : 0;
switch (mode_) {
case Mode::Text:
case Mode::Hex:
if (e.key == Key::Up) scroll(-1);
else if (e.key == Key::Down) scroll(1);
else if (e.key == Key::Left) scroll(-(kRows - 1));
else if (e.key == Key::Right) scroll(kRows - 1);
else if (ch == 't' || ch == 'T') {
if (pager_) pager_->toStart();
hexTop_ = 0;
stale_ = true;
} else if (ch == 'b' || ch == 'B') {
if (mode_ == Mode::Text && pager_) pager_->toEnd();
else scroll(INT32_MAX / 2);
stale_ = true;
}
break;
case Mode::Pcap:
if (e.key == Key::Up) packets_.up();
else if (e.key == Key::Down) packets_.down();
else if (e.key == Key::Left) packets_.pageUp();
else if (e.key == Key::Right) packets_.pageDown();
else if (e.key == Key::Select) openPacket(packets_.selected());
break;
case Mode::Ota:
if (e.key == Key::Select && scan_ && scan_->done && scan_->genuine) confirm_.reset(new DialogModel({"Cancel", "Install"}));
break;
default: break;
}
return true;
}
bool FileViewer::update(uint32_t) {
if (!scan_) return false;
if (scan_->done && !scanShown_) {
scanShown_ = true;
if (scan_->what == Mode::Pcap) packets_.setCount(static_cast<int>(scan_->packets.size()));
return true;
}
int percent = scan_->percent;
if (!scan_->done && percent != shownPercent_) {
shownPercent_ = percent;
return true;
}
return false;
}
void FileViewer::refresh() {
if (mode_ == Mode::Text && stale_ && pager_) {
shown_ = pager_->lines();
stale_ = false;
} else if (mode_ == Mode::Hex && stale_) {
uint8_t buf[kRows * 8];
size_t n = readAt(hexTop_ * 8, buf, sizeof buf);
shown_.clear();
for (size_t at = 0; at < n; at += 8) shown_.push_back(files::hexRow(hexTop_ * 8 + at, buf + at, std::min<size_t>(8, n - at)));
stale_ = false;
} else if (mode_ == Mode::Pcap && scan_ && scan_->done && rowsFrom_ != packets_.firstVisible()) {
// The rows on screen, read from the card once each time the list moves.
rowsFrom_ = packets_.firstVisible();
shown_.clear();
bool tap = scan_->pcap.linkType == files::kLinkLoraTap;
for (int i = rowsFrom_; i < rowsFrom_ + kRows && i < static_cast<int>(scan_->packets.size()); i++) {
uint8_t buf[files::kPcapRecordSize + kMaxPacketBytes];
size_t n = readAt(scan_->packets[i], buf, sizeof buf);
files::PcapRecord r;
lora::RxInfo rx;
if (!files::parsePcapRecord(buf, n, r)) {
shown_.push_back("(unreadable)");
continue;
}
const uint8_t* body = buf + files::kPcapRecordSize;
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
if (tap && files::parseLoraTap(body, have, rx)) {
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
rx.syncWord == meshtastic::kSyncWord},
clockTime(r.seconds)));
} else {
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
}
}
}
}
void FileViewer::draw(Canvas& c) {
const auto& area = theme::kContent;
refresh();
c.setTextDatum(top_left);
theme::Rect body{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
std::string right, keys = "Tab: hex";
bool scanning = scan_ && !scan_->done;
switch (mode_) {
case Mode::Text:
case Mode::Hex: {
c.setFont(&fonts::body);
c.setTextColor(size_ == 0 ? theme::kMuted : theme::kText);
if (size_ == 0) c.drawString("(empty)", 4, body.y + 1);
for (size_t i = 0; i < shown_.size(); i++) c.drawString(shown_[i].c_str(), 4, body.y + 1 + static_cast<int>(i) * theme::kLineHeight);
uint32_t at = mode_ == Mode::Text && pager_ ? pager_->top() : hexTop_ * 8;
if (size_ > 0) { // where in the file the screen is
int barH = 12, barY = body.y + static_cast<int>(static_cast<uint64_t>(body.h - barH) * at / size_);
c.fillRect(area.w - 2, barY, 2, barH, theme::kMuted);
}
right = formatBytes(size_);
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
break;
}
case Mode::Gpx: {
std::vector<std::string> lines;
if (scanning) lines.push_back("Reading the Track... " + std::to_string(scan_->percent.load()) + "%");
else if (scan_ && !scan_->error.empty()) lines.push_back(scan_->error);
else if (scan_) lines = scan_->gpx.lines();
widgets::textLines(c, lines, 0, body);
right = formatBytes(size_);
keys = "Tab: the file as text";
break;
}
case Mode::Pcap: {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
if (scanning) c.drawString("Reading the Capture...", 4, body.y + 1);
else if (scan_ && !scan_->error.empty()) c.drawString(scan_->error.c_str(), 4, body.y + 1);
else if (scan_ && scan_->packets.empty()) c.drawString("No packets in this Capture.", 4, body.y + 1);
else if (scan_) {
int from = rowsFrom_;
widgets::list(c, packets_, body, [&](int i) {
size_t row = static_cast<size_t>(i - from);
return row < shown_.size() ? shown_[row] : std::string();
});
right = std::to_string(scan_->packets.size()) + (scan_->morePackets ? "+ packets" : " packets");
keys = "Enter: the packet Tab: hex";
}
break;
}
case Mode::Packet: widgets::textLines(c, details_, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return;
case Mode::Ota: {
std::vector<std::string> lines;
bool ok = scan_ && scan_->done && scan_->genuine;
if (scanning) {
lines.push_back("Checking it as an install would:");
lines.push_back("signature and contents, " + std::to_string(scan_->percent.load()) + "%");
} else if (scan_) {
if (!scan_->version.empty()) lines.push_back("Version " + scan_->version);
lines.push_back(std::string("Running ") + versionString());
if (ok) {
lines.push_back("Signed with the project's key, intact.");
if (scan_->older) lines.push_back("Older than what's running.");
} else {
lines.push_back("Not installable:");
lines.push_back(scan_->error.empty() ? "it ends too early" : scan_->error);
}
}
widgets::textLines(c, lines, 0, body);
right = formatBytes(size_);
keys = ok ? "Enter: install Tab: hex" : "Tab: hex";
if (confirm_) {
widgets::dialog(c, "Install update?", "The device restarts into " + scan_->version + " once it's written.", *confirm_);
return;
}
break;
}
}
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.setTextDatum(top_left);
c.drawString(title().c_str(), 4, area.y + 1);
c.setTextDatum(top_right);
c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
}
} // namespace roro
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include <FS.h>
#include <memory>
#include <string>
#include <vector>
#include "dialog_model.h"
#include "key_event.h"
#include "list_model.h"
#include "services/storage_service.h"
#include "services/update_service.h"
#include "text_pager.h"
#include "ui/canvas.h"
#include "ui/theme.h"
namespace roro {
// Looks inside a file for the Storage App (F1, Q134), by type: text read as it's scrolled, a hex
// dump, a Capture's packets, a Track's summary, an Update File's version and whether it's
// 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) {}
void open(const std::string& path, uint32_t size);
void close();
bool onKey(const KeyEvent& e); // false: leave the viewer
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
private:
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
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
size_t readAt(uint32_t offset, uint8_t* into, size_t len);
void show(Mode mode);
void startScan(Mode mode);
void refresh(); // reads what the screen shows, when it has moved
void openPacket(int index);
void scroll(int lines);
std::string title() const;
StorageService& storage_;
UpdateService& update_;
std::string path_;
uint32_t size_ = 0;
Mode mode_ = Mode::Text, base_ = Mode::Text;
std::shared_ptr<fs::File> file_; // opened and read on the storage task only
std::unique_ptr<files::TextPager> pager_;
uint32_t hexTop_ = 0; // in rows of eight bytes
std::vector<std::string> shown_; // the rows on screen
bool stale_ = true;
std::shared_ptr<Scan> scan_;
bool scanShown_ = false;
int shownPercent_ = -1;
ListModel packets_{kRows};
int rowsFrom_ = -1;
std::vector<std::string> details_;
int detailsTop_ = 0;
std::unique_ptr<DialogModel> confirm_;
};
} // namespace roro
@@ -1,4 +1,4 @@
#include "storage_page.h"
#include "maintenance_page.h"
#include "ui/widgets.h"
@@ -9,25 +9,25 @@ constexpr int kAges = sizeof(kCleanupAgeLabels) / sizeof(kCleanupAgeLabels[0]);
constexpr int kCategories = sizeof(kCleanupCategories) / sizeof(kCleanupCategories[0]);
} // namespace
void StoragePage::enter() {
void MaintenancePage::enter() {
view_ = View::Main;
dialog_.reset();
main_.setCount(kRows);
main_.select(kCleanUp);
}
void StoragePage::warn(const char* text) {
void MaintenancePage::warn(const char* text) {
bus_.publish(Event::withText(EventType::Notification, text, static_cast<int32_t>(NotificationLevel::Warning)));
}
uint64_t StoragePage::categoryBytes(int category) const {
uint64_t MaintenancePage::categoryBytes(int category) const {
uint64_t sum = 0;
if (category < static_cast<int>(listing_.size()))
for (auto& f : listing_[category]) sum += f.bytes;
return sum;
}
CleanupPlan StoragePage::planFor(int age) const {
CleanupPlan MaintenancePage::planFor(int age) const {
int today = clock_.today();
auto cutoff = static_cast<CleanupAge>(age);
// Without a clock only "Everything" can be judged.
@@ -35,7 +35,7 @@ CleanupPlan StoragePage::planFor(int age) const {
return CleanupPlan::make(listing_[categories_.selected()], cutoff, today);
}
bool StoragePage::onKey(const KeyEvent& e) {
bool MaintenancePage::onKey(const KeyEvent& e) {
if (dialog_) {
dialog_->onKey(e);
int result = dialog_->result();
@@ -109,7 +109,7 @@ bool StoragePage::onKey(const KeyEvent& e) {
return true;
}
void StoragePage::draw(Canvas& c) {
void MaintenancePage::draw(Canvas& c) {
const auto& area = theme::kContent;
if (!haveListing_ && storage_.listingReady()) {
listing_ = storage_.listing();
@@ -15,10 +15,10 @@
namespace roro {
// Settings → Storage: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
class StoragePage {
// Storage → Maintenance: card usage, Storage Clean-up (category → age → confirm) and erasing the card.
class MaintenancePage {
public:
StoragePage(StorageService& storage, ClockService& clock, EventBus& bus)
MaintenancePage(StorageService& storage, ClockService& clock, EventBus& bus)
: storage_(storage), clock_(clock), bus_(bus) {}
void enter();
+2 -10
View File
@@ -27,9 +27,6 @@ bool SettingsApp::onKey(const KeyEvent& e) {
case Page::Menu: return onMenuKey(e);
case Page::Text: return onTextKey(e);
case Page::Choice: return onChoiceKey(e);
case Page::Storage:
if (!storagePage_.onKey(e)) page_ = Page::Menu;
return true;
case Page::About: return onAboutKey(e);
case Page::Wifi:
if (!wifiPage_.onKey(e)) page_ = Page::Menu;
@@ -70,10 +67,6 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) {
case Kind::Slider: break;
case Kind::Page:
switch (menu_.row(i)) {
case Row::Storage:
page_ = Page::Storage;
storagePage_.enter();
break;
case Row::Wifi:
page_ = Page::Wifi;
wifiPage_.enter();
@@ -130,8 +123,8 @@ bool SettingsApp::onAboutKey(const KeyEvent& e) {
}
void SettingsApp::update(uint32_t nowMs) {
// Live values on Storage and About.
bool live = page_ == Page::Storage || page_ == Page::About || page_ == Page::Firmware ||
// Live values on About and Firmware.
bool live = page_ == Page::About || page_ == Page::Firmware ||
(page_ == Page::Wifi && wifiPage_.live());
if (live && nowMs - lastRefreshMs_ >= 500) {
lastRefreshMs_ = nowMs;
@@ -188,7 +181,6 @@ void SettingsApp::draw(Canvas& c) {
widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; });
break;
}
case Page::Storage: storagePage_.draw(c); break;
case Page::About: widgets::textLines(c, aboutLines(), 0, area); break;
case Page::Wifi: wifiPage_.draw(c); break;
case Page::Firmware: firmwarePage_.draw(c); break;
+2 -5
View File
@@ -14,7 +14,6 @@
#include "services/clock_service.h"
#include "services/storage_service.h"
#include "apps/firmware_page.h"
#include "apps/storage_page.h"
#include "apps/wifi_settings_page.h"
#include "settings_menu.h"
#include "ui/theme.h"
@@ -33,14 +32,13 @@ struct SettingsAppDeps {
UpdateService& update;
};
// Settings: every user-facing setting, plus the Storage and About pages.
// Settings: every user-facing setting, plus the Wi-Fi, Firmware and About pages.
class SettingsApp : public App {
public:
explicit SettingsApp(const SettingsAppDeps& deps)
: d_(deps),
menu_(deps.settings),
wifiPage_(deps.settings, deps.savedNetworks, deps.wifi, deps.bus),
storagePage_(deps.storage, deps.clock, deps.bus),
firmwarePage_(deps.update, deps.wifi, deps.storage) {}
void onEnter() override;
bool onKey(const KeyEvent& e) override;
@@ -51,7 +49,7 @@ class SettingsApp : public App {
void draw(Canvas& c) override;
private:
enum class Page { Menu, Text, Choice, Storage, About, Wifi, Firmware };
enum class Page { Menu, Text, Choice, About, Wifi, Firmware };
bool onMenuKey(const KeyEvent& e);
bool onTextKey(const KeyEvent& e);
@@ -63,7 +61,6 @@ class SettingsApp : public App {
SettingsAppDeps d_;
SettingsMenu menu_;
WifiSettingsPage wifiPage_;
StoragePage storagePage_;
FirmwarePage firmwarePage_;
Page page_ = Page::Menu;
ListModel list_{theme::kContent.h / theme::kLineHeight};
+566
View File
@@ -0,0 +1,566 @@
#include "storage_app.h"
#include <Arduino.h>
#include <algorithm>
#include <cstdio>
#include "cleanup_plan.h"
#include "file_names.h"
#include "ui/canvas.h"
#include "ui/fonts.h"
#include "ui/widgets.h"
namespace roro {
using files::baseName;
using files::joinPath;
using files::parentOf;
namespace {
constexpr uint32_t kMessageMs = 4000;
constexpr uint32_t kQuietMs = 300; // an operation shorter than this shows no progress
const char* kindText(const std::string& name) {
switch (files::kindOf(name)) {
case files::FileKind::Text: return "Text";
case files::FileKind::Gpx: return "Track (GPX)";
case files::FileKind::Pcap: return "Capture (pcap)";
case files::FileKind::Ota: return "Update File";
default: return "File";
}
}
std::string plural(uint32_t n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); }
} // namespace
void StorageApp::onEnter() {
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::Browse;
ask_ = Ask::None;
dialog_.reset();
message_.clear();
cardPresent_ = storage_.state().present;
// A copy left running is picked up where it is; anything else starts from a fresh listing.
if (wait_ != Wait::Work && view_ == View::Browse) open(cwd_);
else want_ = cwd_;
requestRedraw();
}
void StorageApp::onExit() {
if (wait_ != Wait::None && wait_ != Wait::Work) {
ops_.cancel();
wait_ = Wait::None;
}
viewer_.close();
wantList_ = false;
loaded_ = false;
list_ = files::FileList(); // its memory only while the App is open
rows_.setCount(0);
std::vector<std::string>().swap(details_);
}
void StorageApp::say(const std::string& text) {
message_ = text;
messageMs_ = millis();
requestRedraw();
}
// What's on screen stays, with its path, until the new listing is in: cwd_ changes then.
void StorageApp::open(const std::string& folder, const std::string& select) {
want_ = folder;
selectAfter_ = select;
wantList_ = true; // started from update(), once the card is free
waitSinceMs_ = millis();
}
StorageApp::Item StorageApp::selected() const {
int i = rows_.selected();
if (!loaded_ || i < 0 || i >= static_cast<int>(list_.count())) return {};
Item item;
item.path = joinPath(cwd_, list_.name(i));
item.folder = list_.folder(i);
return item;
}
bool StorageApp::work(const std::string& why, const std::string& selectAfter) {
if (!why.empty()) {
say(why);
return false;
}
wait_ = Wait::Work;
waitSinceMs_ = millis();
selectAfter_ = selectAfter;
return true;
}
void StorageApp::update(uint32_t nowMs) {
bool present = storage_.state().present;
if (present != cardPresent_) { // taken out, put in, or erased: back to the top
cardPresent_ = present;
cwd_ = want_ = "/";
selectAfter_.clear();
loaded_ = false;
list_.clear();
rows_.setCount(0);
wantList_ = present;
clip_ = Item();
if (view_ == View::Viewer) viewer_.close();
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
requestRedraw();
}
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
wantList_ = false;
wait_ = Wait::List;
waitSinceMs_ = nowMs;
}
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
FileOps::Status s;
if (wait_ != Wait::None && ops_.finished(s)) {
onFinished(s);
requestRedraw();
}
// millis(), not nowMs: a message set by a key in this same pass is newer than the pass's own time.
uint32_t now = millis();
if ((wait_ != Wait::None || wantList_) && now - lastDrawMs_ >= 250) requestRedraw();
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
message_.clear();
requestRedraw();
}
if (view_ == View::Maintenance && now - lastDrawMs_ >= 1000) requestRedraw();
}
void StorageApp::onFinished(const FileOps::Status& s) {
Wait was = wait_;
wait_ = Wait::None;
switch (was) {
case Wait::List: {
if (!s.error.empty()) {
say(s.error);
selectAfter_.clear();
// A folder that can't be opened leaves the screen as it is; if it's the one on
// screen that went away, back to the top.
if (want_ != cwd_ && loaded_) {
want_ = cwd_;
return;
}
if (want_ != "/") return open("/");
list_.clear();
} else {
ops_.takeListing(list_);
list_.sort(sort_);
}
cwd_ = want_;
loaded_ = true;
int count = static_cast<int>(list_.count()) + (cwd_ == "/" ? 1 : 0);
int at = selectAfter_.empty() ? -1 : list_.find(selectAfter_);
rows_.setCount(count);
rows_.select(at >= 0 ? at : selectIndex_);
selectAfter_.clear();
selectIndex_ = 0;
break;
}
case Wait::CountToDelete:
if (!s.error.empty()) say(s.error);
else if (!s.cancelled) askDelete(target_, s.files, s.bytes);
break;
case Wait::CountForDetails:
if (view_ != View::Details || details_.empty() || s.cancelled) break;
details_.back() = s.error.empty() ? plural(s.files, "file") + ", " + formatBytes(s.bytes) : s.error;
break;
case Wait::Work: {
std::string name = files::fitName(baseName(target_.path), 20);
if (s.exists && pasting_) {
question_ = name + " exists already in this folder. Replace it?";
ask_ = Ask::Replace;
dialog_.reset(new DialogModel({"Cancel", "Replace"}));
return;
}
if (!s.error.empty()) say(s.error);
else if (s.cancelled) say(s.op == FileOps::Op::Copy ? "Cancelled: nothing was copied" : "Cancelled after " + plural(s.files, "file"));
else {
switch (s.op) {
case FileOps::Op::Copy: say(s.files == 1 ? "Copied " + name : "Copied " + plural(s.files, "file") + " to " + name); break;
case FileOps::Op::Move: say((pasting_ ? "Moved " : "Renamed to ") + name); break;
case FileOps::Op::Delete: say(s.files == 1 ? "Deleted " + name : "Deleted " + name + ", " + plural(s.files, "file")); break;
default: say("Made " + name); break;
}
// The clipboard must not point at what has moved or gone.
bool gone = s.op == FileOps::Op::Delete || s.op == FileOps::Op::Move;
if (clip_ && gone && files::isInside(clip_.path, s.path)) clip_ = Item();
}
open(cwd_, selectAfter_);
break;
}
default: break;
}
}
void StorageApp::paste(bool replace) {
if (!clip_) return say("Nothing to paste: c copies, x cuts");
std::string name = baseName(clip_.path), why;
if (cut_) {
why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace);
} else {
// A copy next to its original: "a (2).txt", or the next number that's free.
if (parentOf(clip_.path) == cwd_)
for (int n = 2; n < 100 && list_.find(name) >= 0; n++) name = files::copyName(baseName(clip_.path), n);
why = ops_.copy(clip_.path, clip_.folder, cwd_, name, replace);
}
target_.path = joinPath(cwd_, name);
target_.folder = clip_.folder;
pasting_ = true;
selectIndex_ = rows_.selected(); // where to stay if nothing comes of it
work(why, name);
}
void StorageApp::askDelete(const Item& item, uint32_t count, uint32_t bytes) {
std::string name = files::fitName(baseName(item.path), 22);
if (!item.folder) question_ = "Delete " + name + " (" + formatBytes(bytes) + ")?";
else if (count == 0) question_ = "Delete the empty folder " + name + "?";
else question_ = "Delete " + name + " and its " + plural(count, "file") + " (" + formatBytes(bytes) + ")?";
question_ += " It can't be undone.";
target_ = item;
ask_ = Ask::Delete;
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
requestRedraw();
}
void StorageApp::onAnswer(int button) {
Ask asked = ask_;
ask_ = Ask::None;
dialog_.reset();
if (button != 1) return;
switch (asked) {
case Ask::Delete:
selectIndex_ = rows_.selected();
pasting_ = false;
work(ops_.remove(target_.path, target_.folder), "");
break;
case Ask::Replace: paste(true); break;
case Ask::Maintenance:
view_ = View::Maintenance;
maintenance_.enter();
break;
default: break;
}
}
void StorageApp::showDetails(const Item& item) {
int i = rows_.selected();
std::string name = baseName(item.path);
details_.clear();
details_.push_back(name);
if (item.folder) details_.push_back("Folder");
else {
uint32_t size = list_.size(i);
std::string text = std::string(kindText(name)) + ", " + formatBytes(size);
if (size >= 1024) text += " (" + std::to_string(size) + " bytes)";
details_.push_back(text);
}
details_.push_back("Modified " + files::formatStamp(list_.modified(i)));
details_.push_back("In " + cwd_);
std::string why = ops_.whyReadOnly(item.path, item.folder);
if (!why.empty()) details_.push_back("Read-only: " + why);
if (item.folder) {
details_.push_back("Counting...");
if (ops_.count(item.path).empty()) wait_ = Wait::CountForDetails;
else details_.pop_back();
}
detailsTop_ = 0;
view_ = View::Details;
}
bool StorageApp::onKey(const KeyEvent& e) {
requestRedraw();
if (view_ == View::NoMemory) return false;
if (view_ == View::Maintenance) {
if (!maintenance_.onKey(e)) {
view_ = View::Browse;
open(cwd_); // a Clean-up may have emptied what was shown
}
return true;
}
if (view_ == View::Viewer) {
if (!viewer_.onKey(e)) {
viewer_.close();
view_ = View::Browse;
}
return true;
}
if (dialog_) {
dialog_->onKey(e);
if (dialog_->result() != DialogModel::kPending) onAnswer(dialog_->result());
return true;
}
if (view_ == View::Details) {
if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1);
else if (e.key == Key::Down) detailsTop_++; // clamped when drawn
else if (e.key == Key::Back || e.key == Key::Select) {
if (wait_ == Wait::CountForDetails) ops_.cancel();
view_ = View::Browse;
}
return true;
}
// A copy, a delete or a count is running: Back stops it, nothing else gets through. The same
// while a folder is being read, which nothing stops.
if (wantList_) return true;
if (wait_ != Wait::None) {
if (e.key == Key::Back && wait_ != Wait::List) ops_.cancel();
return true;
}
if (view_ == View::Name) return onNameKey(e);
return onBrowseKey(e);
}
bool StorageApp::onNameKey(const KeyEvent& e) {
switch (e.key) {
case Key::Char: editor_.insert(e.ch); break;
case Key::Delete: editor_.backspace(); break;
case Key::Left: editor_.left(); break;
case Key::Right: editor_.right(); break;
case Key::Back: view_ = View::Browse; break;
case Key::Select: {
const std::string& name = editor_.text();
std::string why = files::checkName(name);
if (!why.empty()) {
say(why);
break;
}
pasting_ = false;
bool started = true;
if (!renaming_) {
target_.path = joinPath(cwd_, name);
target_.folder = true;
started = work(ops_.makeFolder(target_.path), name);
} else if (name != baseName(target_.path)) {
std::string to = joinPath(cwd_, name);
started = work(ops_.move(target_.path, target_.folder, to), name);
if (started) target_.path = to;
}
if (started) view_ = View::Browse;
break;
}
default: break;
}
return true;
}
bool StorageApp::onBrowseKey(const KeyEvent& e) {
auto askMaintenance = [this]() {
question_ = "Clean-up and Erase delete files for good: there is no undo.";
ask_ = Ask::Maintenance;
dialog_.reset(new DialogModel({"Back", "Go on"}));
};
if (e.key == Key::Back) {
if (cwd_ == "/" || !cardPresent_) return false; // leaves the App
open(parentOf(cwd_), baseName(cwd_));
return true;
}
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
if (ch == 'm') askMaintenance();
if (!cardPresent_ || !loaded_) return true;
Item item = selected();
switch (e.key) {
case Key::Up: rows_.up(); return true;
case Key::Down: rows_.down(); return true;
case Key::Left: rows_.pageUp(); return true;
case Key::Right: rows_.pageDown(); return true;
case Key::Select:
if (onMaintenanceRow()) askMaintenance();
else if (item && item.folder) open(item.path);
else if (item) {
viewer_.open(item.path, list_.size(rows_.selected()));
view_ = View::Viewer;
}
return true;
case Key::Delete: ch = 'd'; break;
default: break;
}
switch (ch) {
case 'v': paste(false); return true;
case 'n':
renaming_ = false;
editor_.setText("");
view_ = View::Name;
return true;
case 's': {
sort_ = sort_ == files::FileSort::Name ? files::FileSort::Date : sort_ == files::FileSort::Date ? files::FileSort::Size : files::FileSort::Name;
std::string keep = item ? baseName(item.path) : "";
bool onLast = onMaintenanceRow();
list_.sort(sort_);
if (!onLast) rows_.select(std::max(0, list_.find(keep)));
say(sort_ == files::FileSort::Name ? "Sorted by name" : sort_ == files::FileSort::Date ? "Sorted by date, newest first" : "Sorted by size, biggest first");
return true;
}
default: break;
}
if (!item || !(ch == 'c' || ch == 'x' || ch == 'r' || ch == 'd' || ch == 'i')) return true;
std::string name = files::fitName(baseName(item.path), 18);
if (ch == 'i') {
showDetails(item);
return true;
}
if (ch == 'c') {
clip_ = item;
cut_ = false;
say("v pastes a copy of " + name);
return true;
}
// Cut, rename and delete change the original: the rules of Q130 first.
std::string why = ops_.whyReadOnly(item.path, item.folder);
if (!why.empty()) {
say(why);
return true;
}
if (ch == 'x') {
clip_ = item;
cut_ = true;
say("v moves " + name + " here");
} else if (ch == 'r') {
target_ = item;
renaming_ = true;
editor_.setText(baseName(item.path));
view_ = View::Name;
} else if (item.folder) { // 'd': what's inside is counted first (Q132)
target_ = item;
std::string busy = ops_.count(item.path);
if (!busy.empty()) say(busy);
else {
wait_ = Wait::CountToDelete;
waitSinceMs_ = millis();
}
} else {
askDelete(item, 1, list_.size(rows_.selected()));
}
return true;
}
void StorageApp::draw(Canvas& c) {
lastDrawMs_ = millis();
const auto& area = theme::kContent;
c.setTextDatum(top_left);
switch (view_) {
case View::NoMemory:
c.setFont(&fonts::bold);
c.setTextColor(theme::kWarning);
c.drawString("Not enough memory", 4, area.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("Close what's running (IRC, a page", 4, area.y + 22);
c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight);
return;
case View::Maintenance: maintenance_.draw(c); return;
case View::Viewer: viewer_.draw(c); return;
case View::Details: {
std::vector<std::string> lines;
auto measure = widgets::bodyMeasure(c);
for (auto& d : details_)
for (auto& l : wrapText(d, area.w - 12, measure)) lines.push_back(l);
int rows = (area.h - 2) / theme::kLineHeight;
detailsTop_ = std::clamp(detailsTop_, 0, std::max(0, static_cast<int>(lines.size()) - rows));
widgets::textLines(c, lines, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2});
return;
}
default: break;
}
drawBrowse(c);
if (view_ == View::Name) {
theme::Rect box{16, 44, theme::kWidth - 32, 48};
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
c.drawString(renaming_ ? "Rename" : "New folder", box.x + 6, box.y + 4);
widgets::lineEditor(c, editor_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
}
if (dialog_) {
const char* title = ask_ == Ask::Delete ? "Delete" : ask_ == Ask::Replace ? "Replace" : "Maintenance";
widgets::dialog(c, title, question_, *dialog_);
} else if ((wait_ == Wait::Work || wait_ == Wait::CountToDelete) && millis() - waitSinceMs_ >= kQuietMs) {
drawProgress(c);
}
}
void StorageApp::drawBrowse(Canvas& c) {
const auto& area = theme::kContent;
StorageState card = storage_.state();
// Where we are, and how much room is left (Q129).
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string right;
if (card.present) {
right = formatBytes(card.totalBytes - card.usedBytes) + " free";
if (loaded_ && list_.more() > 0) right = "first " + std::to_string(list_.count()) + " of " + std::to_string(list_.count() + list_.more());
}
size_t room = static_cast<size_t>((area.w - 12) / 5) - right.size() - 1;
std::string path = cwd_.size() <= room ? cwd_ : ".." + cwd_.substr(cwd_.size() - (room - 2));
c.drawString(path.c_str(), 4, area.y + 1);
c.setTextDatum(top_right);
c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight};
c.setFont(&fonts::body);
if (!card.present) {
c.setTextColor(theme::kWarning);
c.drawString("No SD card", 4, rows.y + 4);
} else if (!loaded_) {
// Nothing yet: the footer says what's being read.
} else if (rows_.count() == 0) {
c.setTextColor(theme::kMuted);
c.drawString("Nothing in this folder.", 4, rows.y + 4);
c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight);
} else {
int entries = static_cast<int>(list_.count());
auto value = [&](int i) {
return i >= entries ? std::string(">") : files::rowDetail(list_.folder(i), list_.size(i), list_.modified(i));
};
widgets::list(
c, rows_, rows,
[&](int i) {
if (i >= entries) return std::string("Maintenance");
size_t fits = static_cast<size_t>((area.w - 22) / 6) - value(i).size();
return files::fitName(list_.name(i), fits);
},
value);
}
// Footer: a message for a few seconds, what v would paste, or the keys.
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
std::string keys = "c x v:paste r:name d:del n:new i:info s:sort";
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here";
c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9);
}
void StorageApp::drawProgress(Canvas& c) {
FileOps::Status s = ops_.status();
theme::Rect box{20, 40, theme::kWidth - 40, 58};
c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground);
c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent);
c.setTextDatum(top_left);
c.setFont(&fonts::bold);
c.setTextColor(theme::kText);
const char* verb = s.op == FileOps::Op::Copy ? "Copying " : s.op == FileOps::Op::Delete ? "Deleting " : s.op == FileOps::Op::Count ? "Counting " : "Moving ";
c.drawString((verb + files::fitName(baseName(target_.path), 20)).c_str(), box.x + 6, box.y + 4);
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
std::string line = plural(s.files, "file");
if (s.op == FileOps::Op::Copy && s.totalBytes > 0) {
int w = box.w - 12, done = static_cast<int>(static_cast<uint64_t>(s.doneBytes) * w / s.totalBytes);
c.drawRect(box.x + 6, box.y + 21, w, 7, theme::kMuted);
c.fillRect(box.x + 6, box.y + 21, std::min(done, w), 7, theme::kAccent);
line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes);
}
c.drawString(line.c_str(), box.x + 6, box.y + 31);
c.setFont(&fonts::small);
c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10);
}
} // namespace roro
+96
View File
@@ -0,0 +1,96 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "apps/file_viewer.h"
#include "apps/maintenance_page.h"
#include "dialog_model.h"
#include "event_bus.h"
#include "file_list.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/clock_service.h"
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "ui/theme.h"
namespace roro {
// The Storage App (docs/milestones/F1.md): the SD card's folders and files, one item at a time
// with a clipboard (Q131). Enter opens a folder, or a file in its viewer (Q134); Back goes up;
// c copy, x cut, v paste here, r rename, d delete, n new folder, i details, s sort. At the top of the card, the last row is Maintenance:
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
class StorageApp : public App {
public:
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return view_ == View::Name; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
enum class Ask { None, Delete, Replace, Maintenance };
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
static constexpr int kRows = 8;
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q136
struct Item {
std::string path;
bool folder = false;
explicit operator bool() const { return !path.empty(); }
};
void open(const std::string& folder, const std::string& select = "");
void onFinished(const FileOps::Status& s);
bool onBrowseKey(const KeyEvent& e);
bool onNameKey(const KeyEvent& e);
void onAnswer(int button);
Item selected() const; // empty on the Maintenance row or in an empty folder
bool onMaintenanceRow() const { return cwd_ == "/" && rows_.selected() == static_cast<int>(list_.count()); }
void paste(bool replace);
void askDelete(const Item& item, uint32_t files, uint32_t bytes);
void showDetails(const Item& item);
bool work(const std::string& why, const std::string& selectAfter); // an operation was asked for: did it start?
void say(const std::string& text);
void drawBrowse(Canvas& c);
void drawProgress(Canvas& c);
FileOps& ops_;
StorageService& storage_;
EventBus& bus_;
MaintenancePage maintenance_;
FileViewer viewer_;
View view_ = View::Browse;
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
files::FileList list_;
files::FileSort sort_ = files::FileSort::Name;
ListModel rows_{kRows};
bool wantList_ = false, loaded_ = false, cardPresent_ = false;
std::string selectAfter_; // after the next listing: the name to select, or else the row
int selectIndex_ = 0;
Wait wait_ = Wait::None;
uint32_t waitSinceMs_ = 0, lastDrawMs_ = 0;
Item clip_, target_; // the clipboard; what a dialog, the editor or the details are about
bool cut_ = false;
Ask ask_ = Ask::None;
std::unique_ptr<DialogModel> dialog_;
std::string question_;
LineEditor editor_{60};
bool renaming_ = false; // Name view: renaming target_, or else a new folder
bool pasting_ = false; // the operation running came from v: it may ask to replace
std::vector<std::string> details_;
int detailsTop_ = 0;
std::string message_;
uint32_t messageMs_ = 0;
};
} // namespace roro