Notes: edit a text file of any size (#47)
CI / build (pull_request) Successful in 1m52s
Site / build (pull_request) Successful in 12s

The editor held the whole note in memory and stopped at 16 KB. It now keeps
a window of the file around the cursor, and the rest on the card as a list
of pieces (notes::NoteDocument). Memory with a note open is what it was.

Up to 64 KB a save rewrites the file, as before. Above, the five-second
save appends what changed to <note>.edit, and the file is rewritten on
leaving the note, with a progress bar. After a power cut, opening the note
picks the edit up where it was saved; a rewrite cut short is finished or
dropped, never half applied.

Also: Ctrl with Fn+Up/Down go to the start and end of the note; the
consoles' `key` command takes ctrl-, alt- and shift-; the Storage App's
`e` no longer refuses a big file.

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-07 15:39:45 +02:00
co-authored by Claude Opus 5.5
parent 10c5291e15
commit de8af6ed92
21 changed files with 1681 additions and 111 deletions
+183 -78
View File
@@ -9,24 +9,108 @@
#include "cleanup_plan.h"
#include "file_list.h"
#include "file_names.h"
#include "platform/console.h"
#include "ui/fonts.h"
#include "ui/theme.h"
#include "ui/widgets.h"
namespace roro {
using notes::NoteDocument;
using notes::NoteText;
std::function<void(const std::string&, int)> NoteEditor::onProgress;
// The SD card for a NoteDocument. Every call is made on the storage task. The file being read and
// the file being appended to stay open between calls (a window is read in a few pieces, a rewrite
// appends some five hundred blocks to the megabyte), until done().
class NoteEditor::Card : public notes::NoteCard {
public:
explicit Card(StorageService& storage) : storage_(storage) {}
bool size(const std::string& path, uint32_t& size) override {
close(path);
File f = SD.open(path.c_str(), FILE_READ);
if (!f || f.isDirectory()) return false;
size = static_cast<uint32_t>(f.size());
f.close();
return true;
}
size_t read(const std::string& path, uint32_t at, uint8_t* into, size_t len) override {
if (appendPath_ == path) closeAppend(); // what was appended has to be there to read
if (readPath_ != path || !read_) {
closeRead();
read_ = SD.open(path.c_str(), FILE_READ);
if (!read_) return 0;
readPath_ = path;
}
if (!read_.seek(at)) return 0;
int n = read_.read(into, len);
return n > 0 ? static_cast<size_t>(n) : 0;
}
bool create(const std::string& path) override {
close(path);
File f = SD.open(path.c_str(), FILE_WRITE);
if (!f) return false;
f.close();
return true;
}
bool append(const std::string& path, const uint8_t* data, size_t len) override {
if (readPath_ == path) closeRead();
if (appendPath_ != path || !append_) {
closeAppend();
append_ = SD.open(path.c_str(), FILE_APPEND);
if (!append_) return false;
appendPath_ = path;
}
return append_.write(data, len) == len;
}
bool remove(const std::string& path) override {
close(path);
return SD.remove(path.c_str());
}
bool rename(const std::string& from, const std::string& to) override {
done();
return SD.rename(from.c_str(), to.c_str());
}
uint64_t freeBytes() override {
StorageState s = storage_.state();
return s.totalBytes > s.usedBytes ? s.totalBytes - s.usedBytes : 0;
}
void done() override {
closeRead();
closeAppend();
}
private:
void close(const std::string& path) {
if (readPath_ == path) closeRead();
if (appendPath_ == path) closeAppend();
}
void closeRead() {
if (read_) read_.close();
readPath_.clear();
}
void closeAppend() {
if (append_) append_.close();
appendPath_.clear();
}
StorageService& storage_;
File read_, append_;
std::string readPath_, appendPath_;
};
namespace {
constexpr uint32_t kMessageMs = 4000;
// One block the size of a full note, and something left: without it, nothing is opened. Free
// One block the size of the window, 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.
// On the storage task. Reads a file of up to `limit` bytes into a string; 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;
@@ -51,42 +135,43 @@ void NoteEditor::say(const std::string& text) {
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
card_ = std::make_shared<Card>(storage_);
doc_.reset(new NoteDocument(*card_, kCols, kRows));
std::string left, why, told;
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.
// A save of a small note that never finished: its temporary file is offered back (Q143),
// if there's the memory to look at it now. A bigger note's unfinished saves are in its
// side file, and the document picks them up by itself.
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()) {
size_t tmpSize = t ? t.size() : 0;
bool hasTmp = static_cast<bool>(t);
if (t) t.close();
bool hasSide = SD.exists((path + ".edit").c_str());
if (hasTmp && !hasSide && tmpSize > 0 && tmpSize <= NoteText::kMaxBytes && ESP.getMaxAllocHeap() >= kRoomWanted + tmpSize) {
left.reserve(tmpSize);
if (!readWhole(tmp, left, NoteText::kMaxBytes)) std::string().swap(left);
}
why = doc_->open(path, &told);
if (!why.empty()) return;
if (hasTmp && !hasSide && (left.empty() || doc_->windowed() || left == doc_->text().text())) {
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)));
if (!ran) why = "No SD card";
if (!why.empty()) {
doc_.reset();
card_.reset();
return why;
}
path_ = path;
folder_ = files::parentOf(path);
savedRevision_ = text_->revision();
problem_.clear();
message_.clear();
givenUp_ = false;
lastKeyMs_ = millis();
if (!told.empty()) say(told);
if (!left.empty()) {
recovered_ = std::move(left);
ask_ = Ask::Recover;
@@ -98,36 +183,38 @@ std::string NoteEditor::open(const std::string& path) {
std::string NoteEditor::openNew(const std::string& folder) {
close();
if (ESP.getMaxAllocHeap() < kRoomWanted) return kNoRoom;
text_.reset(new NoteText(kCols, kRows));
card_ = std::make_shared<Card>(storage_);
doc_.reset(new NoteDocument(*card_, kCols, kRows));
path_.clear();
folder_ = folder;
savedRevision_ = text_->revision();
problem_.clear();
message_.clear();
givenUp_ = false;
lastKeyMs_ = millis();
return "";
}
// Leaving rewrites the file, however long the note (Q225). Not when the device is powering off:
// then a long note's edits go to its side file, which is quick, and are picked up the next time.
void NoteEditor::close() {
if (dirty()) save();
text_.reset();
if (doc_ && !givenUp_ && owed()) save(!power_.poweringOff());
doc_.reset();
card_.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;
// On the main loop, waiting for the storage task: no second copy of the text is made. A note of
// up to 64 KB is rewritten in a fraction of a second, when nobody has typed for five; a longer
// one takes a second for each 400 KB or so, and shows how far it is.
bool NoteEditor::save(bool whole) {
if (!doc_) 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;
}
if (path_.empty() && doc_->size() == 0) return true; // a new note nothing was typed in: no file
std::string path = path_, why;
if (path.empty()) {
bool fresh = path_.empty();
if (fresh) {
char stamp[20] = "new";
int64_t now = clock_.utcNow();
if (now >= 0) {
@@ -137,10 +224,15 @@ bool NoteEditor::save() {
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");
path = files::joinPath(folder_, notes::nameFromFirstLine(doc_->text().firstLine(), stamp) + ".txt");
}
bool fresh = path_.empty();
bool rewrite = whole || fresh || doc_->wantsRewrite();
int percent = 0;
bool ran = storage_.runAndWait([&]() {
if (!rewrite) {
doc_->journal(why);
return;
}
if (!SD.exists(folder_.c_str()) && !SD.mkdir(folder_.c_str())) {
why = "the card refused to make " + folder_;
return;
@@ -148,48 +240,59 @@ bool NoteEditor::save() {
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));
doc_->setPath(path);
}
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 (doc_->rewriteStart(why)) percent = doc_->rewriteStep(why);
if (fresh && !why.empty()) doc_->setPath("");
});
bool show = doc_->size() > NoteDocument::kWholeLimit;
uint32_t started = millis();
while (ran && rewrite && why.empty() && percent >= 0 && percent < 100) {
if (show && onProgress) onProgress(files::baseName(path), percent);
ran = storage_.runAndWait([&]() { percent = doc_->rewriteStep(why); });
}
if (!ran) why = "no SD card";
if (!why.empty()) {
if (problem_ != why) say("Not saved: " + why);
problem_ = why;
redraw_ = true;
return false;
}
if (rewrite && show) console.printf("notes: rewrote %s, %u bytes in %.1f s\n", path.c_str(), (unsigned)doc_->size(), (millis() - started) / 1000.0);
path_ = path;
problem_.clear();
savedRevision_ = text_->revision();
redraw_ = true;
return true;
}
void NoteEditor::settle() {
if (!doc_ || !doc_->wantsMove()) return;
// A window that leaves memory needs a file to belong to: a new note is saved first.
if (path_.empty() && !save(true)) return;
std::string why;
bool ran = storage_.runAndWait([&]() { doc_->move(why); });
if (!ran) why = "no SD card";
if (!why.empty() && problem_ != why) say("The card: " + why);
if (!why.empty()) problem_ = why;
}
bool NoteEditor::jump(bool toEnd) {
std::string why;
uint32_t to = toEnd ? doc_->size() : 0;
bool ran = storage_.runAndWait([&]() { doc_->jump(to, why); });
if (!ran) why = "no SD card";
if (!why.empty()) say("The card: " + why);
return why.empty();
}
void NoteEditor::help(std::vector<KeyHelp>& out) const {
if (dialog_) return keys::add(out, keys::kDialog);
keys::add(out, keys::kNotesEditor);
}
bool NoteEditor::onKey(const KeyEvent& e) {
if (!text_) return false;
if (!doc_) return false;
NoteText* text_ = &doc_->text();
redraw_ = true;
if (dialog_) {
dialog_->onKey(e);
@@ -210,7 +313,7 @@ bool NoteEditor::onKey(const KeyEvent& e) {
return true;
}
if (asked == Ask::LeaveUnsaved && result == 1) {
savedRevision_ = text_->revision(); // given up on
givenUp_ = true;
return false;
}
return true;
@@ -225,37 +328,38 @@ bool NoteEditor::onKey(const KeyEvent& e) {
else if (lower == 'e') text_->lineEnd();
break;
}
if (!text_->insert(e.ch)) say("This note is full: 16 KB");
if (!text_->insert(e.ch)) say("Can't type: " + problem_);
break;
}
case Key::Select:
if (!text_->insert('\n')) say("This note is full: 16 KB");
if (!text_->insert('\n')) say("Can't type: " + problem_);
break;
case Key::Tab:
if (!text_->insertText(" ")) say("This note is full: 16 KB");
if (!text_->insertText(" ")) say("Can't type: " + problem_);
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::Up: e.ctrl ? void(jump(false)) : page ? text_->pageUp() : text_->up(); break;
case Key::Down: e.ctrl ? void(jump(true)) : page ? text_->pageDown() : text_->down(); break;
case Key::Back:
if (!dirty() || save()) return false;
if (!owed() || save(true)) return false;
ask_ = Ask::LeaveUnsaved;
dialog_.reset(new DialogModel({"Stay", "Leave"}));
break;
default: break;
}
settle();
return true;
}
bool NoteEditor::update(uint32_t) {
if (!text_) return false;
if (!doc_) 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();
save(false);
if (!message_.empty() && now - messageMs_ >= kMessageMs) {
message_.clear();
redraw_ = true;
@@ -266,7 +370,8 @@ bool NoteEditor::update(uint32_t) {
}
void NoteEditor::draw(Canvas& c) {
if (!text_) return;
if (!doc_) return;
NoteText* text_ = &doc_->text();
const auto& area = theme::kContent;
c.setTextDatum(top_left);
@@ -275,7 +380,7 @@ void NoteEditor::draw(Canvas& c) {
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");
std::string state = formatBytes(doc_->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);
@@ -288,9 +393,9 @@ void NoteEditor::draw(Canvas& c) {
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
if (doc_->size() > static_cast<uint32_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.fillRect(area.w - 2, top + (h - barH) * doc_->percent() / 100, 2, barH, theme::kMuted);
}
c.setFont(&fonts::small);
+26 -10
View File
@@ -7,7 +7,9 @@
#include "dialog_model.h"
#include "key_help.h"
#include "key_event.h"
#include "note_text.h"
#include <functional>
#include "note_document.h"
#include "services/clock_service.h"
#include "services/power_service.h"
#include "services/storage_service.h"
@@ -15,10 +17,12 @@
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.
// Edits one text file of any size (F1, Q143-Q145; issue #47, Q223-Q232): the Notes App's editor,
// and the Storage App's for `e`. The text is a notes::NoteDocument: a window of the file in
// memory, the rest on the card. It saves by itself: five seconds after the last key, when the
// screen turns off, and on close. Up to 64 KB a save writes `<file>.tmp` and puts it in the
// note's place; a bigger note's saves go to `<file>.edit`, and the file is rewritten on leaving.
// Either way the note on the card is always a whole one.
class NoteEditor {
public:
static constexpr int kCols = 38, kRows = 8;
@@ -30,9 +34,13 @@ class NoteEditor {
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_); }
bool isOpen() const { return static_cast<bool>(doc_); }
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
// A long rewrite shows how far it is: the editor is waiting for the card meanwhile, so the
// screen is drawn from here (set once, in main).
static std::function<void(const std::string& name, int percent)> onProgress;
bool onKey(const KeyEvent& e); // false: done, and saved
void help(std::vector<KeyHelp>& out) const;
bool update(uint32_t nowMs); // true: draw again
@@ -41,16 +49,24 @@ class NoteEditor {
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)
class Card;
bool dirty() const { return doc_ && doc_->dirty(); }
bool owed() const { return doc_ && (doc_->dirty() || doc_->filePending()); } // the file isn't the note yet
// True if the card has it now (or there was nothing to save). `whole`: the file itself is
// rewritten; otherwise a note over 64 KB only gets its side file written, which is quick.
bool save(bool whole);
void settle(); // after a key: moves the window if the cursor is near an end of it
bool jump(bool toEnd);
void say(const std::string& text);
StorageService& storage_;
ClockService& clock_;
PowerService& power_;
std::unique_ptr<notes::NoteText> text_;
std::shared_ptr<Card> card_;
std::unique_ptr<notes::NoteDocument> doc_;
std::string path_, folder_;
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
uint32_t lastKeyMs_ = 0, lastTryMs_ = 0;
bool givenUp_ = false; // "Leave" after a save that failed
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
Ask ask_ = Ask::None;
std::unique_ptr<DialogModel> dialog_;
+10 -1
View File
@@ -25,6 +25,10 @@ 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;
}
// Beside a note, and not one: a save cut short (.tmp), a long note's unsaved edits (.edit), and
// edits that no longer fit their file, kept for whoever wants to look (.edit.lost).
bool notANote(const std::string& name) { return endsWith(name, ".tmp") || endsWith(name, ".edit") || endsWith(name, ".edit.lost"); }
} // namespace
void NotesApp::onEnter() {
@@ -125,6 +129,7 @@ void NotesApp::onFinished(const FileOps::Status& s) {
if (list_.find(name.substr(0, name.size() - 4)) < 0) orphans.push_back(name);
continue;
}
if (notANote(name)) continue;
notes_.push_back(static_cast<uint16_t>(i));
}
if (!orphans.empty() && !mended_) {
@@ -202,6 +207,8 @@ bool NotesApp::onKey(const KeyEvent& e) {
selectIndex_ = rows_.selected();
selectAfter_.clear();
std::string why = ops_.remove(target_, false);
std::string side = target_ + ".edit"; // a long note's unsaved edits go with it (issue #47)
if (why.empty()) storage_.runJob([side]() { SD.remove(side.c_str()); });
if (why.empty()) wait_ = Wait::Work;
else say(why);
}
@@ -225,6 +232,8 @@ bool NotesApp::onNameKey(const KeyEvent& e) {
if (why.empty() && name != baseName(target_)) {
why = ops_.move(target_, false, joinPath(kFolder, name));
if (why.empty()) {
std::string side = target_ + ".edit", sideTo = joinPath(kFolder, name) + ".edit";
storage_.runJob([side, sideTo]() { SD.rename(side.c_str(), sideTo.c_str()); });
wait_ = Wait::Work;
selectAfter_ = name;
}
@@ -268,7 +277,7 @@ bool NotesApp::onListKey(const KeyEvent& e) {
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));
if (!list_.folder(i) && !notANote(list_.name(i))) 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;
-1
View File
@@ -322,7 +322,6 @@ bool StorageApp::onKey(const KeyEvent& e) {
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);