Public Access
Notes: plain text notes on the SD card, with an editor that saves by itself (#19)
The Notes App lists the files of /notes by their first line, newest first: n starts a note, Enter opens it, r renames its file, d deletes it after asking, s sorts by name. A new note's file is named after its first line. The editor wraps at spaces, 38 columns by 8 rows; Fn+arrows move through the wrapped text, Ctrl+A and Ctrl+E go to the ends of the line. There is no save key: the note is written five seconds after the last key, on Back, on leaving the App, when the screen turns off and before the device powers off. A save writes a temporary file and puts it in the note's place; a save cut short is put back, or offered, the next time. A note is up to 16 KB, held in one buffer reserved when it's opened: the file is read straight into it and typing never makes it grow. A failed allocation aborts on this device, and with IRC connected the largest free block is about 31 KB: a first version that copied the note once on loading restarted the device when a full note was opened with IRC connected. Editing files of any size is #47. The Storage App's text viewer gets `e`, which edits a text file up to 16 KB with the same editor unless the file is read-only. 439 host tests. Checked on the device: docs/milestones/F1.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
@@ -204,6 +204,7 @@ void FileViewer::close() {
|
||||
file_.reset();
|
||||
pager_.reset();
|
||||
confirm_.reset();
|
||||
message_.clear();
|
||||
std::vector<std::string>().swap(shown_);
|
||||
std::vector<std::string>().swap(details_);
|
||||
packets_.setCount(0);
|
||||
@@ -216,6 +217,11 @@ void FileViewer::show(Mode mode) {
|
||||
rowsFrom_ = -1;
|
||||
}
|
||||
|
||||
void FileViewer::say(const std::string& text) {
|
||||
message_ = text;
|
||||
messageMs_ = millis();
|
||||
}
|
||||
|
||||
std::string FileViewer::title() const { return files::fitName(files::baseName(path_), 24); }
|
||||
|
||||
void FileViewer::scroll(int lines) {
|
||||
@@ -314,6 +320,10 @@ bool FileViewer::onKey(const KeyEvent& e) {
|
||||
}
|
||||
|
||||
bool FileViewer::update(uint32_t) {
|
||||
if (!message_.empty() && millis() - messageMs_ >= 4000) {
|
||||
message_.clear();
|
||||
return true;
|
||||
}
|
||||
if (!scan_) return false;
|
||||
if (scan_->done && !scanShown_) {
|
||||
scanShown_ = true;
|
||||
@@ -387,6 +397,7 @@ void FileViewer::draw(Canvas& c) {
|
||||
}
|
||||
right = formatBytes(size_);
|
||||
keys = std::string("Tab: ") + (mode_ != base_ ? "back" : mode_ == Mode::Text ? "hex" : "text") + " t: top b: end";
|
||||
if (mode_ == Mode::Text) keys += " e: edit";
|
||||
break;
|
||||
}
|
||||
case Mode::Gpx: {
|
||||
@@ -452,7 +463,8 @@ void FileViewer::draw(Canvas& c) {
|
||||
c.setTextDatum(top_right);
|
||||
c.drawString(right.c_str(), area.w - 3, area.y + 1);
|
||||
c.setTextDatum(top_left);
|
||||
c.drawString(keys.c_str(), 4, area.y + area.h - 9);
|
||||
if (!message_.empty()) c.setTextColor(theme::kWarning);
|
||||
c.drawString(message_.empty() ? keys.c_str() : message_.c_str(), 4, area.y + area.h - 9);
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -30,6 +30,12 @@ class FileViewer {
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
// For `e` in the Storage App: is this a text small enough to edit (Q146)? And a line to say why not.
|
||||
bool showingText() const { return mode_ == Mode::Text; }
|
||||
const std::string& path() const { return path_; }
|
||||
uint32_t size() const { return size_; }
|
||||
void say(const std::string& text);
|
||||
|
||||
private:
|
||||
enum class Mode { Text, Hex, Gpx, Pcap, Packet, Ota };
|
||||
static constexpr int kRows = 8;
|
||||
@@ -62,6 +68,8 @@ class FileViewer {
|
||||
std::vector<std::string> details_;
|
||||
int detailsTop_ = 0;
|
||||
std::unique_ptr<DialogModel> confirm_;
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
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);
|
||||
c.drawString(showMessage ? message_.c_str() : "Fn+arrows: move Ctrl+A/E: line Back: done", 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
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dialog_model.h"
|
||||
#include "key_event.h"
|
||||
#include "note_text.h"
|
||||
#include "services/clock_service.h"
|
||||
#include "services/power_service.h"
|
||||
#include "services/storage_service.h"
|
||||
#include "ui/canvas.h"
|
||||
|
||||
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.
|
||||
class NoteEditor {
|
||||
public:
|
||||
static constexpr int kCols = 38, kRows = 8;
|
||||
static constexpr uint32_t kSaveAfterMs = 5000;
|
||||
|
||||
NoteEditor(StorageService& storage, ClockService& clock, PowerService& power)
|
||||
: storage_(storage), clock_(clock), power_(power) {}
|
||||
|
||||
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_); }
|
||||
const std::string& path() const { return path_; } // "" for a new note nothing was typed in
|
||||
|
||||
bool onKey(const KeyEvent& e); // false: done, and saved
|
||||
bool update(uint32_t nowMs); // true: draw again
|
||||
void draw(Canvas& c);
|
||||
|
||||
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)
|
||||
void say(const std::string& text);
|
||||
|
||||
StorageService& storage_;
|
||||
ClockService& clock_;
|
||||
PowerService& power_;
|
||||
std::unique_ptr<notes::NoteText> text_;
|
||||
std::string path_, folder_;
|
||||
uint32_t savedRevision_ = 0, lastKeyMs_ = 0, lastTryMs_ = 0;
|
||||
std::string recovered_; // what a temporary file left behind holds, until the user has chosen
|
||||
Ask ask_ = Ask::None;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
std::string message_, problem_;
|
||||
uint32_t messageMs_ = 0;
|
||||
bool redraw_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -0,0 +1,346 @@
|
||||
#include "notes_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#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;
|
||||
}
|
||||
} // 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<uint16_t>().swap(notes_);
|
||||
std::vector<std::string>().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<int>(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<std::string> 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;
|
||||
}
|
||||
notes_.push_back(static_cast<uint16_t>(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<int>(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<int>(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<std::string> paths;
|
||||
for (int i = titlesFrom_; i < titlesFrom_ + kRows && i < static_cast<int>(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<uint8_t*>(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<size_t>(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
|
||||
}
|
||||
|
||||
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);
|
||||
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()) {
|
||||
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) && !endsWith(list_.name(i), ".tmp")) 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;
|
||||
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);
|
||||
c.drawString("n starts one.", 4, rows.y + 4 + theme::kLineHeight);
|
||||
} 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);
|
||||
c.drawString(showMessage ? message_.c_str() : "n: new Enter: open r: name d: del s: sort", 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
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "dialog_model.h"
|
||||
#include "file_list.h"
|
||||
#include "line_editor.h"
|
||||
#include "list_model.h"
|
||||
#include "services/file_ops.h"
|
||||
#include "ui/theme.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
// Notes (docs/milestones/F1.md, Q141-Q150): plain text files in /notes, listed by their first line,
|
||||
// newest first. n starts one, Enter opens it in the editor, which saves by itself; r renames the
|
||||
// file, d deletes it after asking, s sorts by name or by date.
|
||||
class NotesApp : public App {
|
||||
public:
|
||||
NotesApp(FileOps& ops, StorageService& storage, ClockService& clock, PowerService& power)
|
||||
: ops_(ops), storage_(storage), editor_(storage, clock, power) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Edit || view_ == View::Name; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { List, Edit, Name, NoMemory };
|
||||
enum class Wait { None, List, Work };
|
||||
static constexpr int kRows = 8;
|
||||
static constexpr size_t kStartFloor = 55 * 1024; // Q86, Q144
|
||||
static constexpr const char* kFolder = "/notes";
|
||||
|
||||
void reload(const std::string& select = "");
|
||||
void onFinished(const FileOps::Status& s);
|
||||
bool onListKey(const KeyEvent& e);
|
||||
bool onNameKey(const KeyEvent& e);
|
||||
void leaveEditor();
|
||||
std::string selectedPath() const; // "" in an empty list
|
||||
std::string titleOf(int row);
|
||||
void readTitles();
|
||||
void say(const std::string& text);
|
||||
|
||||
FileOps& ops_;
|
||||
StorageService& storage_;
|
||||
NoteEditor editor_;
|
||||
|
||||
View view_ = View::List;
|
||||
files::FileList list_;
|
||||
std::vector<uint16_t> notes_; // the entries of list_ that are notes
|
||||
files::FileSort sort_ = files::FileSort::Date;
|
||||
ListModel rows_{kRows};
|
||||
bool wantList_ = false, loaded_ = false, cardPresent_ = false, mended_ = false;
|
||||
std::string selectAfter_;
|
||||
int selectIndex_ = 0;
|
||||
Wait wait_ = Wait::None;
|
||||
std::vector<std::string> titles_; // of the rows on screen
|
||||
int titlesFrom_ = -1;
|
||||
std::unique_ptr<DialogModel> dialog_;
|
||||
std::string target_, question_;
|
||||
LineEditor name_{40};
|
||||
std::string message_;
|
||||
uint32_t messageMs_ = 0, lastDrawMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -51,6 +51,7 @@ void StorageApp::onExit() {
|
||||
ops_.cancel();
|
||||
wait_ = Wait::None;
|
||||
}
|
||||
noteEditor_.close(); // saves
|
||||
viewer_.close();
|
||||
wantList_ = false;
|
||||
loaded_ = false;
|
||||
@@ -105,7 +106,8 @@ void StorageApp::update(uint32_t nowMs) {
|
||||
wantList_ = present;
|
||||
clip_ = Item();
|
||||
if (view_ == View::Viewer) viewer_.close();
|
||||
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer) view_ = View::Browse;
|
||||
if (view_ == View::Editor) noteEditor_.close();
|
||||
if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer || view_ == View::Editor) view_ = View::Browse;
|
||||
requestRedraw();
|
||||
}
|
||||
if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) {
|
||||
@@ -114,6 +116,7 @@ void StorageApp::update(uint32_t nowMs) {
|
||||
waitSinceMs_ = nowMs;
|
||||
}
|
||||
if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw();
|
||||
if (view_ == View::Editor && noteEditor_.update(nowMs)) requestRedraw();
|
||||
FileOps::Status s;
|
||||
if (wait_ != Wait::None && ops_.finished(s)) {
|
||||
onFinished(s);
|
||||
@@ -280,6 +283,32 @@ bool StorageApp::onKey(const KeyEvent& e) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Editor) {
|
||||
if (!noteEditor_.onKey(e)) {
|
||||
std::string name = baseName(noteEditor_.path());
|
||||
noteEditor_.close();
|
||||
view_ = View::Browse;
|
||||
open(cwd_, name); // its size and date have changed
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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);
|
||||
if (why.empty()) view_ = View::Editor;
|
||||
else {
|
||||
view_ = View::Browse;
|
||||
say(why);
|
||||
}
|
||||
} else {
|
||||
viewer_.say(why);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (view_ == View::Viewer) {
|
||||
if (!viewer_.onKey(e)) {
|
||||
viewer_.close();
|
||||
@@ -453,6 +482,7 @@ void StorageApp::draw(Canvas& c) {
|
||||
return;
|
||||
case View::Maintenance: maintenance_.draw(c); return;
|
||||
case View::Viewer: viewer_.draw(c); return;
|
||||
case View::Editor: noteEditor_.draw(c); return;
|
||||
case View::Details: {
|
||||
std::vector<std::string> lines;
|
||||
auto measure = widgets::bodyMeasure(c);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "app.h"
|
||||
#include "apps/file_viewer.h"
|
||||
#include "apps/maintenance_page.h"
|
||||
#include "apps/note_editor.h"
|
||||
#include "dialog_model.h"
|
||||
#include "event_bus.h"
|
||||
#include "file_list.h"
|
||||
@@ -25,17 +26,17 @@ namespace roro {
|
||||
// usage, Storage Clean-up and erasing the card, behind a warning (Q128).
|
||||
class StorageApp : public App {
|
||||
public:
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update) {}
|
||||
StorageApp(FileOps& ops, StorageService& storage, ClockService& clock, UpdateService& update, PowerService& power, EventBus& bus)
|
||||
: ops_(ops), storage_(storage), bus_(bus), maintenance_(storage, clock, bus), viewer_(storage, update), noteEditor_(storage, clock, power) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
bool onKey(const KeyEvent& e) override;
|
||||
bool textEntryActive() const override { return view_ == View::Name; }
|
||||
bool textEntryActive() const override { return view_ == View::Name || view_ == View::Editor; }
|
||||
void update(uint32_t nowMs) override;
|
||||
void draw(Canvas& c) override;
|
||||
|
||||
private:
|
||||
enum class View { Browse, Details, Name, Viewer, Maintenance, NoMemory };
|
||||
enum class View { Browse, Details, Name, Viewer, Editor, Maintenance, NoMemory };
|
||||
enum class Ask { None, Delete, Replace, Maintenance };
|
||||
enum class Wait { None, List, CountToDelete, CountForDetails, Work }; // what the running operation is for
|
||||
static constexpr int kRows = 8;
|
||||
@@ -67,6 +68,7 @@ class StorageApp : public App {
|
||||
EventBus& bus_;
|
||||
MaintenancePage maintenance_;
|
||||
FileViewer viewer_;
|
||||
NoteEditor noteEditor_; // `e` in the text viewer (Q146)
|
||||
|
||||
View view_ = View::Browse;
|
||||
std::string cwd_ = "/", want_ = "/"; // the folder on screen; the one being read
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@
|
||||
#include "apps/irc_app.h"
|
||||
#include "apps/launcher_app.h"
|
||||
#include "apps/lora_scanner_app.h"
|
||||
#include "apps/notes_app.h"
|
||||
#include "apps/settings_app.h"
|
||||
#include "apps/storage_app.h"
|
||||
#include "apps/system_app.h"
|
||||
@@ -210,7 +211,10 @@ void setup() {
|
||||
apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)});
|
||||
apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)});
|
||||
apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, bus)});
|
||||
apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus)});
|
||||
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
|
||||
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
|
||||
power->beforePowerOff = []() { apps->home(); };
|
||||
apps->registerApp({"system", "System", false,
|
||||
new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)});
|
||||
apps->registerApp({"settings", "Settings", false,
|
||||
|
||||
@@ -42,6 +42,7 @@ void PowerService::applyScreen(ScreenState state) {
|
||||
}
|
||||
|
||||
void PowerService::powerOff() {
|
||||
if (beforePowerOff) beforePowerOff();
|
||||
auto& display = M5Cardputer.Display;
|
||||
display.wakeup();
|
||||
display.setBrightness(settings_.getInt(Setting::Brightness) * 255 / 100);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "power_policy.h"
|
||||
#include "service.h"
|
||||
#include "settings.h"
|
||||
@@ -24,6 +26,9 @@ class PowerService : public Service {
|
||||
|
||||
ScreenState screen() const { return applied_; }
|
||||
|
||||
// Run before the device powers off: the last chance to put on the card what's only in memory.
|
||||
std::function<void()> beforePowerOff;
|
||||
|
||||
private:
|
||||
void applyScreen(ScreenState state);
|
||||
void powerOff();
|
||||
|
||||
Reference in New Issue
Block a user