#include "app_keys.h" #include "note_editor.h" #include #include #include #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(&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(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(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& 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(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(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