Public Access
The Notes App lists the files of /notes by their first line, newest first: n starts a note, Enter opens it, r renames its file, d deletes it after asking, s sorts by name. A new note's file is named after its first line. The editor wraps at spaces, 38 columns by 8 rows; Fn+arrows move through the wrapped text, Ctrl+A and Ctrl+E go to the ends of the line. There is no save key: the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. A save writes a temporary file and puts it in the note's place; a save cut short is put back, or offered, the next time. A note is up to 16 KB, held in one buffer reserved when it's opened: the file is read straight into it and typing never makes it grow. A failed allocation aborts on this device, and with IRC connected the largest free block is about 31 KB: a first version that copied the note once on loading restarted the device when a full note was opened with IRC connected. Editing files of any size is #47. The Storage App's text viewer gets `e`, which edits a text file up to 16 KB with the same editor unless the file is read-only. 439 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
597 lines
23 KiB
C++
597 lines
23 KiB
C++
#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;
|
|
}
|
|
noteEditor_.close(); // saves
|
|
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::Editor) noteEditor_.close();
|
|
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer || view_ == View::Editor) 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();
|
|
if (view_ == View::Editor && noteEditor_.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::Editor) {
|
|
if (!noteEditor_.onKey(e)) {
|
|
std::string name = baseName(noteEditor_.path());
|
|
noteEditor_.close();
|
|
view_ = View::Browse;
|
|
open(cwd_, name); // its size and date have changed
|
|
}
|
|
return true;
|
|
}
|
|
if (view_ == View::Viewer && viewer_.showingText() && e.key == Key::Char && (e.ch == 'e' || e.ch == 'E')) {
|
|
// Edit it (Q146), if the rules and its size allow.
|
|
std::string path = viewer_.path(), why = ops_.whyReadOnly(path, false);
|
|
if (why.empty() && viewer_.size() > notes::NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
|
|
if (why.empty()) {
|
|
viewer_.close();
|
|
why = noteEditor_.open(path);
|
|
if (why.empty()) view_ = View::Editor;
|
|
else {
|
|
view_ = View::Browse;
|
|
say(why);
|
|
}
|
|
} else {
|
|
viewer_.say(why);
|
|
}
|
|
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::Editor: noteEditor_.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
|