Public Access
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
310 lines
11 KiB
C++
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
|