Files
roro9stack/src/apps/notes_app.cpp
T
twislaandClaude Opus 5.5 e8a654a15f Notes: plain text notes on the SD card, with an editor that saves by itself (#19)
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
2026-10-06 10:08:10 +02:00

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