Public Access
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:
@@ -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
|
||||
Reference in New Issue
Block a user