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
347 lines
12 KiB
C++
347 lines
12 KiB
C++
#include "notes_app.h"
|
|
|
|
#include <Arduino.h>
|
|
#include <SD.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
|
|
#include "file_names.h"
|
|
#include "ui/canvas.h"
|
|
#include "ui/fonts.h"
|
|
#include "ui/widgets.h"
|
|
|
|
namespace roro {
|
|
|
|
using files::baseName;
|
|
using files::joinPath;
|
|
|
|
namespace {
|
|
constexpr uint32_t kMessageMs = 4000;
|
|
constexpr size_t kTitleChars = 26;
|
|
|
|
bool endsWith(const std::string& s, const char* tail) {
|
|
size_t n = std::strlen(tail);
|
|
return s.size() >= n && s.compare(s.size() - n, n, tail) == 0;
|
|
}
|
|
} // namespace
|
|
|
|
void NotesApp::onEnter() {
|
|
view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::List;
|
|
dialog_.reset();
|
|
message_.clear();
|
|
cardPresent_ = storage_.state().present;
|
|
mended_ = false;
|
|
if (view_ == View::List) reload();
|
|
requestRedraw();
|
|
}
|
|
|
|
void NotesApp::onExit() {
|
|
editor_.close(); // saves
|
|
if (wait_ == Wait::List) ops_.cancel();
|
|
wait_ = Wait::None;
|
|
wantList_ = loaded_ = false;
|
|
list_ = files::FileList();
|
|
std::vector<uint16_t>().swap(notes_);
|
|
std::vector<std::string>().swap(titles_);
|
|
titlesFrom_ = -1;
|
|
rows_.setCount(0);
|
|
}
|
|
|
|
void NotesApp::say(const std::string& text) {
|
|
message_ = text;
|
|
messageMs_ = millis();
|
|
requestRedraw();
|
|
}
|
|
|
|
void NotesApp::reload(const std::string& select) {
|
|
selectAfter_ = select;
|
|
wantList_ = true;
|
|
}
|
|
|
|
std::string NotesApp::selectedPath() const {
|
|
int i = rows_.selected();
|
|
if (!loaded_ || i < 0 || i >= static_cast<int>(notes_.size())) return "";
|
|
return joinPath(kFolder, list_.name(notes_[i]));
|
|
}
|
|
|
|
void NotesApp::leaveEditor() {
|
|
std::string path = editor_.path();
|
|
editor_.close();
|
|
view_ = View::List;
|
|
reload(path.empty() ? "" : baseName(path));
|
|
}
|
|
|
|
void NotesApp::update(uint32_t nowMs) {
|
|
bool present = storage_.state().present;
|
|
if (present != cardPresent_) {
|
|
cardPresent_ = present;
|
|
if (view_ == View::Name) view_ = View::List;
|
|
loaded_ = false;
|
|
wantList_ = present && view_ == View::List;
|
|
requestRedraw();
|
|
}
|
|
if (view_ == View::Edit) {
|
|
if (editor_.update(nowMs)) requestRedraw();
|
|
return;
|
|
}
|
|
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(kFolder).empty()) {
|
|
wantList_ = false;
|
|
wait_ = Wait::List;
|
|
}
|
|
FileOps::Status s;
|
|
if (wait_ != Wait::None && ops_.finished(s)) {
|
|
onFinished(s);
|
|
requestRedraw();
|
|
}
|
|
if (!message_.empty() && millis() - messageMs_ >= kMessageMs) {
|
|
message_.clear();
|
|
requestRedraw();
|
|
}
|
|
}
|
|
|
|
void NotesApp::onFinished(const FileOps::Status& s) {
|
|
Wait was = wait_;
|
|
wait_ = Wait::None;
|
|
if (was == Wait::Work) {
|
|
if (!s.error.empty()) say(s.error);
|
|
else say(s.op == FileOps::Op::Delete ? "Deleted" : "Renamed");
|
|
return reload(selectAfter_);
|
|
}
|
|
list_.clear();
|
|
notes_.clear();
|
|
if (s.error.empty()) { // no /notes yet is an empty list, not an error
|
|
ops_.takeListing(list_);
|
|
list_.sort(sort_);
|
|
}
|
|
// A save cut between its two last steps left "x.txt.tmp" and no "x.txt": the whole note, under
|
|
// the wrong name. Put back once, then listed again.
|
|
std::vector<std::string> orphans;
|
|
for (size_t i = 0; i < list_.count(); i++) {
|
|
std::string name = list_.name(i);
|
|
if (list_.folder(i)) continue;
|
|
if (endsWith(name, ".tmp")) {
|
|
if (list_.find(name.substr(0, name.size() - 4)) < 0) orphans.push_back(name);
|
|
continue;
|
|
}
|
|
notes_.push_back(static_cast<uint16_t>(i));
|
|
}
|
|
if (!orphans.empty() && !mended_) {
|
|
mended_ = true;
|
|
storage_.runAndWait([&]() {
|
|
for (auto& name : orphans) {
|
|
std::string from = joinPath(kFolder, name);
|
|
SD.rename(from.c_str(), from.substr(0, from.size() - 4).c_str());
|
|
}
|
|
});
|
|
return reload(selectAfter_);
|
|
}
|
|
loaded_ = true;
|
|
titlesFrom_ = -1;
|
|
rows_.setCount(static_cast<int>(notes_.size()));
|
|
int at = -1;
|
|
for (size_t i = 0; i < notes_.size() && !selectAfter_.empty(); i++)
|
|
if (selectAfter_ == list_.name(notes_[i])) at = static_cast<int>(i);
|
|
rows_.select(at >= 0 ? at : selectIndex_);
|
|
selectAfter_.clear();
|
|
selectIndex_ = 0;
|
|
}
|
|
|
|
// The first line of each note on screen, read when the list moves: eight short reads.
|
|
void NotesApp::readTitles() {
|
|
if (titlesFrom_ == rows_.firstVisible()) return;
|
|
titlesFrom_ = rows_.firstVisible();
|
|
titles_.clear();
|
|
std::vector<std::string> paths;
|
|
for (int i = titlesFrom_; i < titlesFrom_ + kRows && i < static_cast<int>(notes_.size()); i++) paths.push_back(joinPath(kFolder, list_.name(notes_[i])));
|
|
titles_.resize(paths.size());
|
|
storage_.runAndWait([&]() {
|
|
for (size_t i = 0; i < paths.size(); i++) {
|
|
File f = SD.open(paths[i].c_str(), FILE_READ);
|
|
if (!f) continue;
|
|
char head[120];
|
|
int n = f.read(reinterpret_cast<uint8_t*>(head), sizeof head);
|
|
f.close();
|
|
if (n > 0) titles_[i] = notes::titleFrom(std::string(head, n), kTitleChars);
|
|
}
|
|
});
|
|
}
|
|
|
|
std::string NotesApp::titleOf(int row) {
|
|
size_t i = static_cast<size_t>(row - titlesFrom_);
|
|
if (i < titles_.size() && !titles_[i].empty()) return titles_[i];
|
|
return files::fitName(list_.name(notes_[row]), kTitleChars); // an empty note, or not a text
|
|
}
|
|
|
|
bool NotesApp::onKey(const KeyEvent& e) {
|
|
requestRedraw();
|
|
if (view_ == View::NoMemory) return false;
|
|
if (view_ == View::Edit) {
|
|
if (!editor_.onKey(e)) leaveEditor();
|
|
return true;
|
|
}
|
|
if (dialog_) {
|
|
dialog_->onKey(e);
|
|
int result = dialog_->result();
|
|
if (result == DialogModel::kPending) return true;
|
|
dialog_.reset();
|
|
if (result == 1) {
|
|
selectIndex_ = rows_.selected();
|
|
selectAfter_.clear();
|
|
std::string why = ops_.remove(target_, false);
|
|
if (why.empty()) wait_ = Wait::Work;
|
|
else say(why);
|
|
}
|
|
return true;
|
|
}
|
|
if (wantList_ || wait_ != Wait::None) return true; // a moment
|
|
if (view_ == View::Name) return onNameKey(e);
|
|
return onListKey(e);
|
|
}
|
|
|
|
bool NotesApp::onNameKey(const KeyEvent& e) {
|
|
switch (e.key) {
|
|
case Key::Char: name_.insert(e.ch); break;
|
|
case Key::Delete: name_.backspace(); break;
|
|
case Key::Left: name_.left(); break;
|
|
case Key::Right: name_.right(); break;
|
|
case Key::Back: view_ = View::List; break;
|
|
case Key::Select: {
|
|
std::string name = name_.text() + ".txt";
|
|
std::string why = files::checkName(name);
|
|
if (why.empty() && name != baseName(target_)) {
|
|
why = ops_.move(target_, false, joinPath(kFolder, name));
|
|
if (why.empty()) {
|
|
wait_ = Wait::Work;
|
|
selectAfter_ = name;
|
|
}
|
|
}
|
|
if (why.empty()) view_ = View::List;
|
|
else say(why);
|
|
break;
|
|
}
|
|
default: break;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool NotesApp::onListKey(const KeyEvent& e) {
|
|
if (e.key == Key::Back) return false;
|
|
if (!cardPresent_ || !loaded_) return true;
|
|
uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0;
|
|
std::string path = selectedPath();
|
|
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 (path.empty()) return true;
|
|
std::string why = editor_.open(path);
|
|
if (why.empty()) view_ = View::Edit;
|
|
else say(why);
|
|
return true;
|
|
}
|
|
case Key::Delete: ch = 'd'; break;
|
|
default: break;
|
|
}
|
|
if (ch == 'n') {
|
|
std::string why = editor_.openNew(kFolder);
|
|
if (why.empty()) view_ = View::Edit;
|
|
else say(why);
|
|
} else if (ch == 's') {
|
|
sort_ = sort_ == files::FileSort::Date ? files::FileSort::Name : files::FileSort::Date;
|
|
std::string keep = path.empty() ? "" : baseName(path);
|
|
list_.sort(sort_);
|
|
notes_.clear();
|
|
for (size_t i = 0; i < list_.count(); i++)
|
|
if (!list_.folder(i) && !endsWith(list_.name(i), ".tmp")) notes_.push_back(static_cast<uint16_t>(i));
|
|
for (size_t i = 0; i < notes_.size(); i++)
|
|
if (keep == list_.name(notes_[i])) rows_.select(static_cast<int>(i));
|
|
titlesFrom_ = -1;
|
|
say(sort_ == files::FileSort::Date ? "Newest first" : "By file name");
|
|
} else if (path.empty()) {
|
|
// nothing selected: the keys below need a note
|
|
} else if (ch == 'd') {
|
|
target_ = path;
|
|
question_ = "Delete " + files::fitName(baseName(path), 22) + ", \"" + titleOf(rows_.selected()).substr(0, 20) + "\"? It can't be undone.";
|
|
dialog_.reset(new DialogModel({"Cancel", "Delete"}));
|
|
} else if (ch == 'r') {
|
|
target_ = path;
|
|
std::string name = baseName(path);
|
|
size_t dot = name.rfind('.');
|
|
name_.setText(dot == std::string::npos || dot == 0 ? name : name.substr(0, dot));
|
|
view_ = View::Name;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void NotesApp::draw(Canvas& c) {
|
|
lastDrawMs_ = millis();
|
|
const auto& area = theme::kContent;
|
|
c.setTextDatum(top_left);
|
|
if (view_ == 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 Notes again.", 4, area.y + 22 + theme::kLineHeight);
|
|
return;
|
|
}
|
|
if (view_ == View::Edit) return editor_.draw(c);
|
|
|
|
c.setFont(&fonts::small);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString(kFolder, 4, area.y + 1);
|
|
if (loaded_) {
|
|
std::string count = std::to_string(notes_.size()) + (notes_.size() == 1 ? " note" : " notes");
|
|
if (list_.more() > 0) count = "the first " + std::to_string(notes_.size()) + " notes";
|
|
c.setTextDatum(top_right);
|
|
c.drawString(count.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 (!storage_.state().present) {
|
|
c.setTextColor(theme::kWarning);
|
|
c.drawString("No SD card", 4, rows.y + 4);
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("Notes are kept on the card.", 4, rows.y + 4 + theme::kLineHeight);
|
|
} else if (!loaded_) {
|
|
// a moment
|
|
} else if (notes_.empty()) {
|
|
c.setTextColor(theme::kMuted);
|
|
c.drawString("No notes yet.", 4, rows.y + 4);
|
|
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
|
|
} else {
|
|
readTitles();
|
|
widgets::list(
|
|
c, rows_, rows, [&](int i) { return titleOf(i); },
|
|
[&](int i) {
|
|
std::string stamp = files::formatStamp(list_.modified(notes_[i]));
|
|
return stamp.size() >= 10 ? stamp.substr(0, 10) : stamp;
|
|
});
|
|
}
|
|
|
|
c.setFont(&fonts::small);
|
|
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
|
|
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
|
|
c.drawString(showMessage ? message_.c_str() : "n: new Enter: open r: name d: del s: sort", 4, area.y + area.h - 9);
|
|
|
|
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("File name (.txt)", box.x + 6, box.y + 4);
|
|
widgets::lineEditor(c, name_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4});
|
|
}
|
|
if (dialog_) widgets::dialog(c, "Delete", question_, *dialog_);
|
|
}
|
|
|
|
} // namespace roro
|