Public Access
`w` in the Storage App starts a small HTTP server and shows its address, as a QR code and in letters, with a six-digit code. A browser on the same network that has typed the code can list, download, upload, make folders and delete, under the Storage App's rules. The server runs only while that screen is open. Nothing is encrypted, and the screen says so. Uploads are streamed to the card under a temporary name and renamed when whole. Every access to the card is handed to the storage task, 8 KB at a time, from the server's own task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
676 lines
26 KiB
C++
676 lines
26 KiB
C++
#include "app_keys.h"
|
|
#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() {
|
|
share_.stop(); // sharing lasts as long as its screen
|
|
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 (view_ == View::Share) {
|
|
if (!present || !share_.running()) {
|
|
share_.stop();
|
|
view_ = View::Browse;
|
|
}
|
|
if (nowMs - lastDrawMs_ >= 500) requestRedraw(); // what the browser is doing
|
|
}
|
|
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: copy or cut something first");
|
|
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;
|
|
}
|
|
|
|
void StorageApp::help(std::vector<KeyHelp>& out) const {
|
|
switch (view_) {
|
|
case View::NoMemory: return;
|
|
case View::Maintenance: return maintenance_.help(out);
|
|
case View::Editor: return noteEditor_.help(out);
|
|
case View::Viewer: return viewer_.help(out);
|
|
case View::Details: return keys::add(out, keys::kStorageDetails);
|
|
case View::Share: return keys::add(out, keys::kStorageShare);
|
|
default: break;
|
|
}
|
|
if (dialog_) return keys::add(out, keys::kDialog);
|
|
if (wantList_ || wait_ != Wait::None) return keys::add(out, keys::kStorageBusy);
|
|
if (view_ == View::Name) return keys::add(out, keys::kStorageName);
|
|
keys::add(out, keys::kStorage);
|
|
}
|
|
|
|
const char* StorageApp::helpTitle() const {
|
|
switch (view_) {
|
|
case View::Maintenance: return "Storage: maintenance";
|
|
case View::Editor: return "Storage: the editor";
|
|
case View::Viewer: return "Storage: a file";
|
|
case View::Details: return "Storage: details";
|
|
case View::Share: return "Storage: sharing";
|
|
case View::Name: return "Storage: a name";
|
|
default: return nullptr;
|
|
}
|
|
}
|
|
|
|
bool StorageApp::onKey(const KeyEvent& e) {
|
|
requestRedraw();
|
|
if (view_ == View::NoMemory) return false;
|
|
if (view_ == View::Share) {
|
|
if (e.key == Key::Back) {
|
|
share_.stop();
|
|
view_ = View::Browse;
|
|
open(cwd_); // what was uploaded or deleted meanwhile
|
|
}
|
|
return true;
|
|
}
|
|
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_.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 (ch == 'w') {
|
|
std::string why = share_.start();
|
|
if (why.empty()) view_ = View::Share;
|
|
else say(why);
|
|
return true;
|
|
}
|
|
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("Copied " + name + ": paste it where you like");
|
|
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("Cut " + name + ": paste it where it should go");
|
|
} 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::Share: drawShare(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);
|
|
}
|
|
}
|
|
|
|
// The address as a QR code and in letters, the code to type, and what the browser has done.
|
|
void StorageApp::drawShare(Canvas& c) {
|
|
const auto& area = theme::kContent;
|
|
std::string url = share_.url(), code = share_.code();
|
|
const int qr = 104;
|
|
c.fillRect(2, area.y + 3, qr + 6, qr + 6, 0xFFFF); // a QR code wants a quiet border
|
|
c.qrcode((url + "#" + code).c_str(), 5, area.y + 6, qr);
|
|
int x = qr + 14, y = area.y + 4;
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("In a browser, on", x, y);
|
|
c.drawString("this network:", x, y + 10);
|
|
c.setTextColor(theme::kText);
|
|
c.drawString(url.size() > 7 ? url.substr(7).c_str() : url.c_str(), x, y + 24); // without http://
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("Code", x, y + 40);
|
|
c.setFont(&fonts::bold);
|
|
c.setTextColor(theme::kAccent);
|
|
std::string grouped = code.size() == 6 ? code.substr(0, 3) + " " + code.substr(3) : code;
|
|
c.drawString(grouped.c_str(), x, y + 50);
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
std::string moved = formatBytes(share_.bytesIn()) + " in, " + formatBytes(share_.bytesOut()) + " out";
|
|
c.drawString(share_.requests() ? moved.c_str() : "Nothing asked yet", x, y + 72);
|
|
c.drawString("Not encrypted.", x, y + 84);
|
|
c.drawString("` stops sharing", x, y + 96);
|
|
c.setTextColor(theme::kText);
|
|
std::string last = share_.last();
|
|
if (!last.empty()) c.drawString(last.c_str(), 4, area.y + area.h - 9);
|
|
}
|
|
|
|
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);
|
|
} 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, or what a paste would do. Never the keys (Fn+h).
|
|
c.setFont(&fonts::small);
|
|
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
|
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
|
std::string keys;
|
|
if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "...";
|
|
else if (clip_) keys = std::string(cut_ ? "Cut: " : "Copied: ") + files::fitName(baseName(clip_.path), 28);
|
|
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);
|
|
}
|
|
|
|
} // namespace roro
|