Files
roro9stack/src/apps/note_editor.cpp
T
twislaandClaude Opus 5.5 34e6714785
CI / build (pull_request) Successful in 7m14s
Site / build (pull_request) Successful in 8s
Keys: one table file for the device's help panel and the website's key tables (#72)
Every screen's keys are constant tables in lib/core/src/app_keys.h (52 of
them, each under an `// id: Title` comment). An App's help() picks the table
of the state it is in. site/tools/gen_dev_docs.py reads the same file and
writes site/data/keys.toml; the `keys` shortcode shows a screen's tables on
its guide page, and /guide/keys/ shows all of them.

The Site job fails when the data file is out of date or a page asks for a
table that doesn't exist, and now also runs when app_keys.h changes. A key
added to an App shows up on the website without anyone editing a page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-07 01:42:48 +02:00

310 lines
11 KiB
C++

#include "app_keys.h"
#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;
}
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;
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);
if (showMessage) c.drawString(message_.c_str(), 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