#include "app_keys.h" #include "notes_app.h" #include #include #include #include #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; } // 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() { 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().swap(notes_); std::vector().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(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 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; } if (notANote(name)) continue; notes_.push_back(static_cast(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(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(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 paths; for (int i = titlesFrom_; i < titlesFrom_ + kRows && i < static_cast(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(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(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 } void NotesApp::help(std::vector& out) const { if (view_ == View::Edit) return editor_.help(out); if (dialog_) return keys::add(out, keys::kDialog); if (view_ == View::Name) return keys::add(out, keys::kNotesName); if (view_ == View::NoMemory) return; keys::add(out, keys::kNotes); } const char* NotesApp::helpTitle() const { return view_ == View::Edit ? "Notes: the editor" : view_ == View::Name ? "Notes: a file name" : nullptr; } 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); 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); } 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()) { 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; } } 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) && !notANote(list_.name(i))) notes_.push_back(static_cast(i)); for (size_t i = 0; i < notes_.size(); i++) if (keep == list_.name(notes_[i])) rows_.select(static_cast(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); } 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); if (showMessage) c.drawString(message_.c_str(), 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