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
This commit is contained in:
2026-10-06 10:08:10 +02:00
co-authored by Claude Opus 5.5
parent c868977f1c
commit e8a654a15f
17 changed files with 1599 additions and 9 deletions
+13 -1
View File
@@ -204,6 +204,7 @@ void FileViewer::close() {
file_.reset();
pager_.reset();
confirm_.reset();
message_.clear();
std::vector<std::string>().swap(shown_);
std::vector<std::string>().swap(details_);
packets_.setCount(0);
@@ -216,6 +217,11 @@ void FileViewer::show(Mode mode) {
rowsFrom_ = -1;
}
void FileViewer::say(const std::string& text) {
message_ = text;
messageMs_ = millis();
}
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
void FileViewer::scroll(int lines) {
@@ -314,6 +320,10 @@ bool FileViewer::onKey(const KeyEvent& e) {
}
bool FileViewer::update(uint32_t) {
if (!message_.empty() && millis() - messageMs_ >= 4000) {
message_.clear();
return true;
}
if (!scan_) return false;
if (scan_->done && !scanShown_) {
scanShown_ = true;
@@ -387,6 +397,7 @@ void FileViewer::draw(Canvas& c) {
}
right = formatBytes(size_);
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
if (mode_ == Mode::Text) keys += " e: edit";
break;
}
case Mode::Gpx: {
@@ -452,7 +463,8 @@ void FileViewer::draw(Canvas& c) {
c.setTextDatum(top_right);
c.drawString(right.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
if (!message_.empty()) c.setTextColor(theme::kWarning);
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
}
} // namespace roro
+8
View File
@@ -30,6 +30,12 @@ class FileViewer {
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
// For `e` in the Storage App: is this a text small enough to edit (Q146)? And a line to say why not.
bool showingText() const { return mode_ == Mode::Text; }
const std::string& path() const { return path_; }
uint32_t size() const { return size_; }
void say(const std::string& text);
private:
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
static constexpr int kRows = 8;
@@ -62,6 +68,8 @@ class FileViewer {
std::vector<std::string> details_;
int detailsTop_ = 0;
std::unique_ptr<DialogModel> confirm_;
std::string message_;
uint32_t messageMs_ = 0;
};
} // namespace roro
+303
View File
@@ -0,0 +1,303 @@
#include "note_editor.h"
#include <Arduino.h>
#include <SD.h>
#include <ctime>
#include "cleanup_plan.h"
#include "file_list.h"
#include "file_names.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
using notes::NoteText;
namespace {
constexpr uint32_t kMessageMs = 4000;
// One block the size of a full note, and something left: without it, nothing is opened. Free
// memory in total isn't the measure: with IRC connected the largest free block is about 31 KB.
constexpr size_t kRoomWanted = NoteText::kMaxBytes + 8 * 1024;
const char* const kNoRoom = "Not enough memory to edit: close IRC or a Gemini page";
// On the storage task. Reads a file of up to `limit` bytes into a string that has that capacity
// already; false if it can't be read whole.
bool readWhole(const std::string& path, std::string& into, size_t limit) {
File f = SD.open(path.c_str(), FILE_READ);
if (!f) return false;
size_t size = f.size();
if (size > limit) {
f.close();
return false;
}
into.resize(size);
size_t got = size ? f.read(reinterpret_cast<uint8_t*>(&into[0]), size) : 0;
f.close();
return got == size;
}
} // namespace
void NoteEditor::say(const std::string& text) {
message_ = text;
messageMs_ = millis();
redraw_ = true;
}
std::string NoteEditor::open(const std::string& path) {
close();
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
std::string body, left, why;
body.reserve(NoteText::kMaxBytes); // the note's own buffer from here on: read into, then handed over
bool ran = storage_.runAndWait([&]() {
File f = SD.open(path.c_str(), FILE_READ);
if (!f) {
why = "The card refused to open it";
return;
}
size_t size = f.size();
f.close();
if (size > NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most";
else if (!readWhole(path, body, NoteText::kMaxBytes)) why = "The card refused to read it";
if (!why.empty()) return;
// A save that never finished: its temporary file is offered back (Q143), if there's the
// memory to look at it now. If not, it stays for the next time.
std::string tmp = path + ".tmp";
File t = SD.open(tmp.c_str(), FILE_READ);
if (!t) return;
size_t tmpSize = t.size();
t.close();
if (tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() < tmpSize + 8 * 1024) return;
left.reserve(tmpSize <= NoteText::kMaxBytes ? tmpSize : 0);
if (!readWhole(tmp, left, NoteText::kMaxBytes) || left == body || left.empty()) {
std::string().swap(left);
SD.remove(tmp.c_str());
}
});
if (!ran) return "No SD card";
if (!why.empty()) return why;
text_.reset(new NoteText(kCols, kRows, std::move(body)));
path_ = path;
folder_ = files::parentOf(path);
savedRevision_ = text_->revision();
problem_.clear();
message_.clear();
lastKeyMs_ = millis();
if (!left.empty()) {
recovered_ = std::move(left);
ask_ = Ask::Recover;
dialog_.reset(new DialogModel({"Keep the note", "Use the copy"}));
}
return "";
}
std::string NoteEditor::openNew(const std::string& folder) {
close();
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
text_.reset(new NoteText(kCols, kRows));
path_.clear();
folder_ = folder;
savedRevision_ = text_->revision();
problem_.clear();
message_.clear();
lastKeyMs_ = millis();
return "";
}
void NoteEditor::close() {
if (dirty()) save();
text_.reset();
dialog_.reset();
ask_ = Ask::None;
std::string().swap(recovered_);
}
// On the main loop, waiting for the storage task: no second copy of the note is made, and at
// 16 KB the wait is a fraction of a second, when nobody has typed for five.
bool NoteEditor::save() {
if (!text_) return true;
lastTryMs_ = millis();
const std::string& body = text_->text();
if (path_.empty() && body.empty()) { // a new note nothing was typed in: no file
savedRevision_ = text_->revision();
return true;
}
std::string path = path_, why;
if (path.empty()) {
char stamp[20] = "new";
int64_t now = clock_.utcNow();
if (now >= 0) {
time_t t = static_cast<time_t>(now);
struct tm local;
localtime_r(&t, &local);
std::snprintf(stamp, sizeof stamp, "%04d%02d%02d-%02d%02d", local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
local.tm_min);
}
path = files::joinPath(folder_, notes::nameFromFirstLine(text_->firstLine(), stamp) + ".txt");
}
bool fresh = path_.empty();
bool ran = storage_.runAndWait([&]() {
if (!SD.exists(folder_.c_str()) && !SD.mkdir(folder_.c_str())) {
why = "the card refused to make " + folder_;
return;
}
if (fresh) { // a name nothing has yet: "list (2).txt"
std::string name = files::baseName(path);
for (int n = 2; n < 100 && SD.exists(path.c_str()); n++) path = files::joinPath(folder_, files::copyName(name, n));
}
std::string tmp = path + ".tmp";
File f = SD.open(tmp.c_str(), FILE_WRITE);
if (!f) {
why = "the card refused to open a file";
return;
}
size_t wrote = body.empty() ? 0 : f.write(reinterpret_cast<const uint8_t*>(body.data()), body.size());
f.close();
File check = SD.open(tmp.c_str(), FILE_READ);
bool whole = wrote == body.size() && check && check.size() == body.size();
if (check) check.close();
if (!whole) {
SD.remove(tmp.c_str());
why = "the card refused a write";
return;
}
// FAT can't rename onto a file. Between these two lines only the temporary file exists:
// the Notes list puts such a file back under its name.
if (SD.exists(path.c_str())) SD.remove(path.c_str());
if (!SD.rename(tmp.c_str(), path.c_str())) why = "the card refused to rename the file";
});
if (!ran) why = "no SD card";
if (!why.empty()) {
if (problem_ != why) say("Not saved: " + why);
problem_ = why;
return false;
}
path_ = path;
problem_.clear();
savedRevision_ = text_->revision();
redraw_ = true;
return true;
}
bool NoteEditor::onKey(const KeyEvent& e) {
if (!text_) return false;
redraw_ = true;
if (dialog_) {
dialog_->onKey(e);
int result = dialog_->result();
if (result == DialogModel::kPending) return true;
Ask asked = ask_;
ask_ = Ask::None;
dialog_.reset();
if (asked == Ask::Recover) {
if (result == 1) {
if (text_->setText(recovered_)) say("The unsaved copy is back"); // different from the card: saved in five seconds
else say("That copy is too big to edit");
} else {
std::string tmp = path_ + ".tmp";
storage_.runJob([tmp]() { SD.remove(tmp.c_str()); });
}
std::string().swap(recovered_);
return true;
}
if (asked == Ask::LeaveUnsaved && result == 1) {
savedRevision_ = text_->revision(); // given up on
return false;
}
return true;
}
lastKeyMs_ = millis();
bool page = e.shift || e.alt;
switch (e.key) {
case Key::Char: {
uint32_t lower = e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch;
if (e.ctrl) {
if (lower == 'a') text_->lineStart();
else if (lower == 'e') text_->lineEnd();
break;
}
if (!text_->insert(e.ch)) say("This note is full: 16 KB");
break;
}
case Key::Select:
if (!text_->insert('\n')) say("This note is full: 16 KB");
break;
case Key::Tab:
if (!text_->insertText(" ")) say("This note is full: 16 KB");
break;
case Key::Delete: text_->backspace(); break;
case Key::Left: text_->left(); break;
case Key::Right: text_->right(); break;
case Key::Up: page ? text_->pageUp() : text_->up(); break;
case Key::Down: page ? text_->pageDown() : text_->down(); break;
case Key::Back:
if (!dirty() || save()) return false;
ask_ = Ask::LeaveUnsaved;
dialog_.reset(new DialogModel({"Stay", "Leave"}));
break;
default: break;
}
return true;
}
bool NoteEditor::update(uint32_t) {
if (!text_) return false;
uint32_t now = millis();
// A save that failed is tried again every five seconds, not at every pass.
if (dirty() && !dialog_ && now - lastTryMs_ >= kSaveAfterMs &&
(now - lastKeyMs_ >= kSaveAfterMs || power_.screen() == ScreenState::Off))
save();
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
message_.clear();
redraw_ = true;
}
bool r = redraw_;
redraw_ = false;
return r;
}
void NoteEditor::draw(Canvas& c) {
if (!text_) return;
const auto& area = theme::kContent;
c.setTextDatum(top_left);
// The name, and whether the card has what's on screen.
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
std::string name = path_.empty() ? "New note" : files::fitName(files::baseName(path_), 26);
c.drawString(name.c_str(), 4, area.y + 1);
std::string state = formatBytes(text_->text().size()) + (dirty() ? (problem_.empty() ? ", typing" : ", NOT SAVED") : ", saved");
if (path_.empty() && !dirty()) state = "empty";
c.setTextDatum(top_right);
c.setTextColor(dirty() && !problem_.empty() ? theme::kWarning : theme::kMuted);
c.drawString(state.c_str(), area.w - 3, area.y + 1);
c.setTextDatum(top_left);
int top = area.y + 10;
auto rows = text_->rows();
c.setFont(&fonts::body);
c.setTextColor(theme::kText);
for (size_t i = 0; i < rows.size(); i++) c.drawString(rows[i].c_str(), 4, top + 1 + static_cast<int>(i) * theme::kLineHeight);
c.fillRect(3 + text_->cursorCol() * 6, top + text_->cursorRow() * theme::kLineHeight, 1, theme::kLineHeight, theme::kAccent);
if (text_->text().size() > static_cast<size_t>(kCols * kRows)) { // more than a screen: where we are in it
int h = kRows * theme::kLineHeight, barH = 12;
c.fillRect(area.w - 2, top + (h - barH) * text_->percent() / 100, 2, barH, theme::kMuted);
}
c.setFont(&fonts::small);
bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs;
c.setTextColor(showMessage ? theme::kWarning : theme::kMuted);
c.drawString(showMessage ? message_.c_str() : "Fn+arrows: move Ctrl+A/E: line Back: done", 4, area.y + area.h - 9);
if (dialog_ && ask_ == Ask::Recover)
widgets::dialog(c, "Unsaved copy", "A save of this note was cut short. Its copy has " + formatBytes(recovered_.size()) + ", the note " +
formatBytes(text_->text().size()) + ".",
*dialog_);
else if (dialog_)
widgets::dialog(c, "Not saved", "The note couldn't be saved: " + problem_ + ". Leave and lose what was typed?", *dialog_);
}
} // namespace roro
+60
View File
@@ -0,0 +1,60 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "dialog_model.h"
#include "key_event.h"
#include "note_text.h"
#include "services/clock_service.h"
#include "services/power_service.h"
#include "services/storage_service.h"
#include "ui/canvas.h"
namespace roro {
// Edits one text file of up to 16 KB (F1, Q143-Q145): the Notes App's editor, and the Storage
// App's for `e`. It saves by itself: five seconds after the last key, when the screen turns off,
// and on close. A save writes `<file>.tmp`, then puts it in the note's place, so the note on the
// card is always a whole one.
class NoteEditor {
public:
static constexpr int kCols = 38, kRows = 8;
static constexpr uint32_t kSaveAfterMs = 5000;
NoteEditor(StorageService& storage, ClockService& clock, PowerService& power)
: storage_(storage), clock_(clock), power_(power) {}
std::string open(const std::string& path); // "" or why it can't be edited
std::string openNew(const std::string& folder); // the same; no file until there's something to save (Q142)
void close(); // saves what isn't yet
bool isOpen() const { return static_cast<bool>(text_); }
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
bool onKey(const KeyEvent& e); // false: done, and saved
bool update(uint32_t nowMs); // true: draw again
void draw(Canvas& c);
private:
enum class Ask { None, Recover, LeaveUnsaved };
bool dirty() const { return text_ && text_->revision() != savedRevision_; }
bool save(); // true if the card has it now (or there was nothing to save)
void say(const std::string& text);
StorageService& storage_;
ClockService& clock_;
PowerService& power_;
std::unique_ptr<notes::NoteText> text_;
std::string path_, folder_;
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
Ask ask_ = Ask::None;
std::unique_ptr<DialogModel> dialog_;
std::string message_, problem_;
uint32_t messageMs_ = 0;
bool redraw_ = false;
};
} // namespace roro
+346
View File
@@ -0,0 +1,346 @@
#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
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include "app.h"
#include "apps/note_editor.h"
#include "dialog_model.h"
#include "file_list.h"
#include "line_editor.h"
#include "list_model.h"
#include "services/file_ops.h"
#include "ui/theme.h"
namespace roro {
// Notes (docs/milestones/F1.md, Q141-Q150): plain text files in /notes, listed by their first line,
// newest first. n starts one, Enter opens it in the editor, which saves by itself; r renames the
// file, d deletes it after asking, s sorts by name or by date.
class NotesApp : public App {
public:
NotesApp(FileOps& ops, StorageService& storage, ClockService& clock, PowerService& power)
: ops_(ops), storage_(storage), editor_(storage, clock, power) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
enum class View { List, Edit, Name, NoMemory };
enum class Wait { None, List, Work };
static constexpr int kRows = 8;
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q144
static constexpr const char* kFolder = "/notes";
void reload(const std::string& select = "");
void onFinished(const FileOps::Status& s);
bool onListKey(const KeyEvent& e);
bool onNameKey(const KeyEvent& e);
void leaveEditor();
std::string selectedPath() const; // "" in an empty list
std::string titleOf(int row);
void readTitles();
void say(const std::string& text);
FileOps& ops_;
StorageService& storage_;
NoteEditor editor_;
View view_ = View::List;
files::FileList list_;
std::vector<uint16_t> notes_; // the entries of list_ that are notes
files::FileSort sort_ = files::FileSort::Date;
ListModel rows_{kRows};
bool wantList_ = false, loaded_ = false, cardPresent_ = false, mended_ = false;
std::string selectAfter_;
int selectIndex_ = 0;
Wait wait_ = Wait::None;
std::vector<std::string> titles_; // of the rows on screen
int titlesFrom_ = -1;
std::unique_ptr<DialogModel> dialog_;
std::string target_, question_;
LineEditor name_{40};
std::string message_;
uint32_t messageMs_ = 0, lastDrawMs_ = 0;
};
} // namespace roro
+31 -1
View File
@@ -51,6 +51,7 @@ void StorageApp::onExit() {
ops_.cancel();
wait_ = Wait::None;
}
noteEditor_.close(); // saves
viewer_.close();
wantList_ = false;
loaded_ = false;
@@ -105,7 +106,8 @@ void StorageApp::update(uint32_t nowMs) {
wantList_ = present;
clip_ = Item();
if (view_ == View::Viewer) viewer_.close();
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
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()) {
@@ -114,6 +116,7 @@ void StorageApp::update(uint32_t nowMs) {
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);
@@ -280,6 +283,32 @@ bool StorageApp::onKey(const KeyEvent& e) {
}
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();
@@ -453,6 +482,7 @@ void StorageApp::draw(Canvas& c) {
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);
+6 -4
View File
@@ -7,6 +7,7 @@
#include "app.h"
#include "apps/file_viewer.h"
#include "apps/maintenance_page.h"
#include "apps/note_editor.h"
#include "dialog_model.h"
#include "event_bus.h"
#include "file_list.h"
@@ -25,17 +26,17 @@ namespace roro {
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
class StorageApp : public App {
public:
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
bool textEntryActive() const override { return view_ == View::Name; }
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
void update(uint32_t nowMs) override;
void draw(Canvas& c) override;
private:
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
enum class Ask { None, Delete, Replace, Maintenance };
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
static constexpr int kRows = 8;
@@ -67,6 +68,7 @@ class StorageApp : public App {
EventBus& bus_;
MaintenancePage maintenance_;
FileViewer viewer_;
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
View view_ = View::Browse;
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
+5 -1
View File
@@ -13,6 +13,7 @@
#include "apps/irc_app.h"
#include "apps/launcher_app.h"
#include "apps/lora_scanner_app.h"
#include "apps/notes_app.h"
#include "apps/settings_app.h"
#include "apps/storage_app.h"
#include "apps/system_app.h"
@@ -210,7 +211,10 @@ void setup() {
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, bus)});
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
apps->registerApp({"system", "System", false,
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
apps->registerApp({"settings", "Settings", false,
+1
View File
@@ -42,6 +42,7 @@ void PowerService::applyScreen(ScreenState state) {
}
void PowerService::powerOff() {
if (beforePowerOff) beforePowerOff();
auto& display = M5Cardputer.Display;
display.wakeup();
display.setBrightness(settings_.getInt(Setting::Brightness) * 255 / 100);
+5
View File
@@ -1,5 +1,7 @@
#pragma once
#include <functional>
#include "power_policy.h"
#include "service.h"
#include "settings.h"
@@ -24,6 +26,9 @@ class PowerService : public Service {
ScreenState screen() const { return applied_; }
// Run before the device powers off: the last chance to put on the card what's only in memory.
std::function<void()> beforePowerOff;
private:
void applyScreen(ScreenState state);
void powerOff();