Compare commits

...
5 Commits
Author SHA1 Message Date
twislaandClaude Opus 5.5 a35d82c654 F1 plan: Notes ships as v0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:11:31 +02:00
twislaandClaude Opus 5.5 e8a654a15f 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
2026-10-06 10:08:10 +02:00
twislaandClaude Opus 5.5 c868977f1c A key sent through the Debug Console could turn the screen "off" for a tick
The `key` command stamps the power timer from millis(); the power tick
then compared with its pass's older time, and the unsigned difference read
as 49 days without a key. The screen state went Off for one tick, and the
next key was swallowed as a wake-up: about one remote key in twenty-five.
Keys from the keyboard pass the loop's own time and were never affected.
The same shape as #46. PowerPolicy::update now treats a stamp from the
future as "just now", with a test.

rdbg.py: piped lines written while it was still connecting stayed in
Python's read buffer until the next line arrived (readline() behind
select()). It reads the descriptor directly now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:08:10 +02:00
twislaandClaude Opus 5.5 4ab873e9f8 F1 plan: the Notes design round (Q141-Q150)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 09:29:38 +02:00
twislaandClaude Opus 5.5 50fcf6b7a3 Merge branch 'f1': the Storage App
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 09:03:50 +02:00
20 changed files with 1658 additions and 13 deletions
+4
View File
@@ -128,6 +128,10 @@ The Notification raised once per boot when the SD card passes 80% full. It point
**Storage Clean-up**:
The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion. It lives in the Storage App, under Maintenance.
**Note**:
A plain text file in `/notes`, written on the device in the Notes App. Listed by its first line. Saved without being asked; never offered by Storage Clean-up.
_Avoid_: memo, document
**Storage App**:
The App that browses the SD card: folders and files, a clipboard for one item at a time (copy, cut, paste), rename, delete, new folder, and a viewer for each kind of file the firmware writes. The top-level folders, `/gemini/cache` and files being written are read-only.
_Avoid_: file manager, Files, explorer
+11 -1
View File
@@ -99,7 +99,7 @@ A copy runs in the background of the card (about 400 KB a second) in short turns
Enter on a file opens it by type; Tab switches to the same file as a hex dump or as text:
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end.
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it (up to 16 KB, see Notes).
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
@@ -107,6 +107,16 @@ Enter on a file opens it by type; Tab switches to the same file as a hex dump or
At the top of the card the last row, **Maintenance** (also `m`), holds the card's usage, Storage Clean-up and "Erase SD card", behind a warning: those delete for good. It replaces Settings > Storage.
## Notes
The Notes App (docs/milestones/F1.md) keeps plain text notes in `/notes` on the SD card. The list shows each note's first line and its date, newest first; `s` switches to by file name. `n` starts a note, Enter opens one, `r` renames its file, `d` deletes it after asking.
In the editor, type. Enter starts a line, Del deletes backwards, Fn with the arrows moves the cursor through the wrapped text, Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, and the Compose Key gives accents as everywhere. **There's 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. The top line says "typing" or "saved". Each save writes a temporary file and then puts it in the note's place, so a power cut costs a few seconds of typing and never the note; if a save was cut short, opening the note offers its copy back.
A new note has no file until something is typed; its file is then named after its first line (`shopping-list.txt`), or `note-<date>-<time>.txt`.
A note holds up to 16 KB while it's edited. A bigger text file opens read-only in the Storage App (editing any size is issue #47). The Storage App's text viewer has `e` to edit a file with the same editor, anywhere on the card, unless the file is read-only.
## Development aids
`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts.
+74 -1
View File
@@ -1,6 +1,6 @@
# F1 — Files and Notes
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) and the card as a USB drive (#1) come after.
**Status:** in progress. The Storage App (issue #3) shipped as **v0.9.0** on 2026-10-06. Notes (#19) shipped as **v0.10.0** the same day. The card as a USB drive (#1) comes after.
**Goal:** get at what's on the SD card from the device itself: browse it, look inside the files the firmware writes, copy, move, rename and delete, and keep notes. A side milestone, like G1 and S1; Files and Notes were M3's original second half (Q30, Q89).
@@ -86,3 +86,76 @@ All in a scratch folder, `/f1test`, removed afterwards.
**One slip during the checks:** a scripted key sequence ran in the wrong folder and renamed `/gemini/saved` to `saved2`, then copied it to the top of the card. Both were put right at once (renamed back, the copy deleted; 7 files, 53,798 bytes, as before).
**Found on the way:** a panic at Wi-Fi join, there since v0.7.0 (SNTP started twice, issue #46). Fixed in v0.9.0.
## Notes (issue #19)
Plain text notes on the SD card, written on the device. Q30 settled the base: `.txt` files in `/notes`, created, edited and deleted from the device, never offered by Storage Clean-up.
### Decisions (design round 2026-10-06)
| # | Decision |
|---|---|
| Q141 | A **Notes** App in the Launcher. One row per note: its first line as the title, then the date. Newest first; `s` switches to by name. `n` new, Enter opens, `d` deletes after a confirmation, `r` renames the file. |
| Q142 | A new note's file name is never typed: it comes from the first line when the note is first saved (`shopping-list.txt`), or `note-20261006-0919.txt` if that line is empty. It doesn't change afterwards unless the note is renamed. |
| Q143 | **Autosave, no "discard changes?" prompt:** five seconds after the last key, on leaving the note or the App, and when the screen turns off. A save writes a temporary file and renames it over the note, so a power cut loses the last few seconds at most. A temporary file left behind is offered back at the next open. |
| Q144 | The whole note is in memory while it's edited, up to **16 KB**. A bigger text file opens read-only in the Storage App's viewer. The App refuses to open below the memory floors (Q86). **Editing files of any size must come in a later release: issue #47.** |
| Q145 | The editor wraps at spaces, 38 columns by 8 rows, with a line for the name and the state. Enter is a new line, Del deletes backwards, Fn+arrows move (the Text Entry rule), Ctrl+A and Ctrl+E go to the start and the end of the line, Tab types two spaces, Back saves and returns. The Compose Key works as elsewhere. |
| Q146 | The Storage App's text viewer gets `e`: edit this file with the same editor, for a text file up to 16 KB that isn't read-only. That lifts Q135 without Apps opening each other (#43 stays). |
| Q147 | The list is flat: the files directly in `/notes`. Sub-folders are reached through the Storage App. |
| Q148 | UTF-8, LF line ends; a file with CRLF is saved back with LF. Characters the font lacks are kept on save. |
| Q149 | Left out, each with its issue: editing files of any size (#47), searching inside notes (#48), undo (#49), selecting and copying text (#50). |
| Q150 | Ships as **v0.10.0** when built and checked on the device. |
### Done when
- A note can be started, typed with accents, left and found again in the list under its first line; renamed; deleted after a confirmation.
- What's typed is on the card five seconds after the last key, and after Back, Home, or the screen turning off, without a prompt.
- Pulling the power while typing loses a few seconds at most, and the note is never left empty or half-written.
- The cursor moves by character and by line through wrapped text, and the screen follows it; a 16 KB note edits without lag.
- A note at 16 KB refuses more text and says so; a bigger file opens read-only.
- `e` in the Storage App's text viewer edits a file; a read-only one is refused with its reason.
- Free heap stays above the floors with a 16 KB note open and IRC connected.
### Work breakdown
1. **Model** (host-tested): the text buffer with its cursor, wrapping and scrolling; file names from first lines.
2. **The editor on the device:** loading, drawing, keys, autosave through a temporary file, recovery.
3. **The Notes App:** the list with titles, new, rename, delete.
4. **`e` in the Storage App.**
5. **Checks on the device**, recorded here.
### As built
- **`NoteText`** (`lib/notes`, host-tested) is the text, its cursor and the screen around it. A line owns the space or the newline it ends with, so every byte is on exactly one line and the cursor has one place for each. **No index of lines is kept:** a note of newlines alone would need twice its own size for one. Where a line starts is worked out from the start of its paragraph.
- **One buffer, 16 KB, for as long as the editor is open.** It's reserved when the note is opened, the file is read straight into it, and typing never makes it grow. On this device a failed allocation is an abort, and with IRC connected the largest free block is about 31 KB whatever the total says: the first version read the file into one string and copied it into another, and opening a full note with IRC connected restarted the device. The editor now also refuses to open without a free block of 24 KB.
- **`NoteEditor`** (`src/apps/note_editor`) is shared by the Notes App and the Storage App's `e`. A save runs on the storage task while the main loop waits for it: no second copy of the note, and at 16 KB the wait is a fraction of a second at a moment when nobody has typed for five.
- **A save** writes `<note>.tmp`, checks its size, deletes the note and renames the temporary file (FAT can't rename onto a file). A cut between the last two steps leaves only the `.tmp`: the Notes list puts such a file back under its name. A `.tmp` next to its note is an unfinished save: opening the note offers it.
- **Titles** in the list are read from the card for the eight rows on screen, when the list moves.
- **Before powering off**, the firmware now leaves the foreground App (`PowerService::beforePowerOff`), which makes the editor save.
- Shift or Alt with Fn+Up and Fn+Down moves a page (not in Q145).
### Found on the way
- **The screen could go "off" for one tick after a key sent through the Debug Console**, and the next key was then swallowed as a wake-up: the `key` command stamps the power timer from `millis()`, the power tick compares with its pass's older time, and the unsigned difference read as 49 days idle. The same shape as #46. Fixed in `PowerPolicy::update` with a test. Keys from the keyboard were never affected. It explains remote keys "lost" in earlier sessions.
- **`scripts/rdbg.py` held back piped lines** written while it was still connecting, until the next line came (a buffered `readline()` behind `select()`). Fixed.
### Checks on the device (2026-10-06, Debug Build of branch `notes`)
Test notes were made in `/notes` and removed afterwards; the folder is left, empty.
| Check | Result |
|---|---|
| Host tests | 439 pass |
| A first note | "No notes yet", `n`, typed three lines: the top line says "typing", then "saved" five seconds after the last key, under `shopping-list.txt`. 63 keys in a row all arrived |
| Leaving | Back saves and returns to the list, which shows the note under its first line. Home in the middle of a new note saved it as `ideas.txt` |
| The cursor | Down, Right, an insertion in the middle of a line; the screen scrolls through a note of about 230 lines |
| A power cut | Typed, waited seven seconds, typed more and restarted the device at once (`reset`): the note has what was saved, whole, and not the last keys |
| An unfinished save | A `.tmp` next to its note: "Unsaved copy... Keep the note / Use the copy"; using it brings its text back and saves it. A `.tmp` alone was put back under its name when the list opened |
| 16 KB | A note of exactly 16,384 bytes opens and scrolls; one more character: "This note is full: 16 KB". A file of 16,398 bytes: "Too big to edit: 16 KB at most" |
| Rename, delete, sort | `r` renamed `orphan.txt` to `orphan2.txt`; `d` asked, then deleted; `s` switched between newest first and by file name |
| `e` in the Storage App | A note opened from the text viewer, edited, saved on Back; the listing shows its new size |
| Memory | IRC connected, the full 16 KB note open: 55 KB free, largest block 31.7 KB (72 KB free before opening) |
**Not checked:** accents through the Compose Key and Ctrl+A / Ctrl+E (the remote `key` command can't send them; the model's tests cover both), the power button's save (it needs a hand on the device), a missing card, and how typing feels on the keyboard itself.
**One slip during the checks:** a key sequence sent right after a restart opened IRC instead of Notes, and the test letters went into IRC's input line. Nothing was sent: the line was cleared and the App left. IRC connected to Libera as it does when opened.
+329
View File
@@ -0,0 +1,329 @@
#include "note_text.h"
#include <algorithm>
namespace roro::notes {
namespace {
bool continuation(char c) { return (static_cast<uint8_t>(c) & 0xC0) == 0x80; }
} // namespace
NoteText::NoteText(int cols, int rows) : cols_(std::max(1, cols)), rows_(std::max(1, rows)) { text_.reserve(kMaxBytes); }
NoteText::NoteText(int cols, int rows, std::string&& text) : cols_(std::max(1, cols)), rows_(std::max(1, rows)), text_(std::move(text)) {
dropCarriageReturns();
if (text_.size() > kMaxBytes) text_.resize(kMaxBytes); // the caller checks sizes: not reached
text_.reserve(kMaxBytes);
}
void NoteText::dropCarriageReturns() {
size_t kept = 0;
for (size_t i = 0; i < text_.size(); i++)
if (!(text_[i] == '\r' && i + 1 < text_.size() && text_[i + 1] == '\n')) text_[kept++] = text_[i];
text_.resize(kept);
}
bool NoteText::setText(const std::string& text) {
size_t kept = text.size();
for (size_t i = 0; i + 1 < text.size(); i++)
if (text[i] == '\r' && text[i + 1] == '\n') kept--;
if (kept > kMaxBytes) return false;
text_.assign(text);
dropCarriageReturns();
cursor_ = top_ = 0;
goal_ = -1;
revision_++;
return true;
}
size_t NoteText::nextLine(size_t start) const {
size_t size = text_.size();
int count = 0;
size_t lastSpace = 0;
bool space = false;
for (size_t i = start; i < size; i++) {
char c = text_[i];
if (c == '\n') return i + 1;
if (continuation(c)) continue;
if (count == cols_) { // one character too many: wrap
if (c == ' ') return i + 1; // the space stays at the end of this line
if (space) return lastSpace + 1; // after the last space that fits
return i; // a word longer than the screen is cut
}
count++;
if (c == ' ') {
lastSpace = i;
space = true;
}
}
return size;
}
size_t NoteText::lineOf(size_t pos) const {
size_t size = text_.size();
pos = std::min(pos, size);
size_t a = pos; // the start of the paragraph: after the newline before `pos`
while (a > 0 && text_[a - 1] != '\n') a--;
while (a < size) {
size_t n = nextLine(a);
if (n > pos) return a;
// The end of the text is on the last line, unless that line ended with a newline: then
// it's on an empty line of its own.
if (n == size && pos == size && text_[size - 1] != '\n') return a;
a = n;
}
return a;
}
bool NoteText::hasLineAfter(size_t start) const {
size_t n = nextLine(start), size = text_.size();
if (n < size) return true;
return start < size && lineOf(size) != start; // the empty line after a final newline
}
size_t NoteText::lastSpot(size_t start) const {
size_t n = nextLine(start), size = text_.size();
if (n == start) return start; // the empty line at the end
if (n == size && lineOf(size) == start) return size;
size_t p = n - 1; // before the newline, the space or the last character the line ends with
while (p > start && continuation(text_[p])) p--;
return p;
}
int NoteText::columnOf(size_t start, size_t pos) const {
int col = 0;
for (size_t i = start; i < pos && i < text_.size(); i++)
if (!continuation(text_[i])) col++;
return col;
}
size_t NoteText::atColumn(size_t start, int col) const {
size_t last = lastSpot(start), p = start;
while (p < last && col > 0) {
p++;
while (p < last && continuation(text_[p])) p++;
col--;
}
return p;
}
void NoteText::moved(bool keepGoal) {
if (!keepGoal) goal_ = -1;
}
bool NoteText::insertText(const std::string& s) {
if (text_.size() + s.size() > kMaxBytes) return false;
text_.insert(cursor_, s);
cursor_ += s.size();
revision_++;
moved();
return true;
}
bool NoteText::insert(uint32_t cp) {
std::string s;
if (cp < 0x80) s += static_cast<char>(cp);
else if (cp < 0x800) {
s += static_cast<char>(0xC0 | (cp >> 6));
s += static_cast<char>(0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
s += static_cast<char>(0xE0 | (cp >> 12));
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
s += static_cast<char>(0x80 | (cp & 0x3F));
} else {
s += static_cast<char>(0xF0 | (cp >> 18));
s += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
s += static_cast<char>(0x80 | (cp & 0x3F));
}
return insertText(s);
}
void NoteText::backspace() {
if (cursor_ == 0) return;
size_t from = cursor_ - 1;
while (from > 0 && continuation(text_[from])) from--;
text_.erase(from, cursor_ - from);
cursor_ = from;
revision_++;
moved();
}
void NoteText::left() {
if (cursor_ == 0) return;
cursor_--;
while (cursor_ > 0 && continuation(text_[cursor_])) cursor_--;
moved();
}
void NoteText::right() {
if (cursor_ >= text_.size()) return;
cursor_++;
while (cursor_ < text_.size() && continuation(text_[cursor_])) cursor_++;
moved();
}
void NoteText::up() {
size_t line = lineOf(cursor_);
if (goal_ < 0) goal_ = columnOf(line, cursor_);
if (line == 0) return;
cursor_ = atColumn(lineOf(line - 1), goal_);
moved(true);
}
void NoteText::down() {
size_t line = lineOf(cursor_);
if (goal_ < 0) goal_ = columnOf(line, cursor_);
if (!hasLineAfter(line)) return;
size_t next = nextLine(line);
cursor_ = next >= text_.size() ? text_.size() : atColumn(next, goal_);
moved(true);
}
void NoteText::pageUp() {
for (int i = 1; i < rows_; i++) up();
}
void NoteText::pageDown() {
for (int i = 1; i < rows_; i++) down();
}
void NoteText::lineStart() {
cursor_ = lineOf(cursor_);
moved();
}
void NoteText::lineEnd() {
cursor_ = lastSpot(lineOf(cursor_));
moved();
}
void NoteText::toStart() {
cursor_ = 0;
moved();
}
void NoteText::toEnd() {
cursor_ = text_.size();
moved();
}
void NoteText::follow() {
size_t line = lineOf(cursor_);
top_ = lineOf(std::min(top_, text_.size())); // an edit above may have moved where lines start
if (line < top_) {
top_ = line;
return;
}
size_t a = top_;
for (int i = 0; i < rows_; i++) {
if (a == line) return; // on screen
if (!hasLineAfter(a)) return;
a = nextLine(a);
}
// Below the screen: the cursor's line becomes the last row.
top_ = line;
for (int i = 1; i < rows_ && top_ > 0; i++) top_ = lineOf(top_ - 1);
}
std::vector<std::string> NoteText::rows() {
follow();
std::vector<std::string> out;
size_t a = top_, size = text_.size();
for (int i = 0; i < rows_; i++) {
size_t n = nextLine(a);
std::string row = text_.substr(a, n - a);
if (!row.empty() && row.back() == '\n') row.pop_back();
for (char& c : row)
if (c == '\t') c = ' ';
out.push_back(std::move(row));
if (!hasLineAfter(a)) break;
a = n >= size ? size : n;
}
return out;
}
int NoteText::cursorRow() {
follow();
size_t line = lineOf(cursor_), a = top_;
for (int i = 0; i < rows_; i++) {
if (a == line) return i;
a = nextLine(a);
}
return rows_ - 1;
}
int NoteText::cursorCol() { return columnOf(lineOf(cursor_), cursor_); }
// At most a screen's worth of it: a note that is one line of 16 KB must not be copied whole.
std::string NoteText::firstLine() const { return text_.substr(0, std::min<size_t>(text_.find('\n'), 160)); }
namespace {
// U+00C0 to U+00FF as the plain letters a file name gets; 0: left out.
const char kPlain[64] = {
'a', 'a', 'a', 'a', 'a', 'a', 0, 'c', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'i', // À..Ï
0, 'n', 'o', 'o', 'o', 'o', 'o', 0, 'o', 'u', 'u', 'u', 'u', 'y', 0, 's', // Ð..ß
'a', 'a', 'a', 'a', 'a', 'a', 0, 'c', 'e', 'e', 'e', 'e', 'i', 'i', 'i', 'i', // à..ï
0, 'n', 'o', 'o', 'o', 'o', 'o', 0, 'o', 'u', 'u', 'u', 'u', 'y', 0, 'y'}; // ð..ÿ
// Drops a character the end of the string cuts in two.
void dropPartial(std::string& s) {
size_t k = s.size();
while (k > 0 && continuation(s[k - 1]) && s.size() - k < 3) k--;
if (k == 0) return;
uint8_t lead = static_cast<uint8_t>(s[k - 1]);
size_t want = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1;
if (s.size() - (k - 1) < want) s.resize(k - 1);
}
} // namespace
std::string nameFromFirstLine(const std::string& firstLine, const std::string& stamp) {
std::string out;
bool dash = false;
for (size_t i = 0; i < firstLine.size() && out.size() < 32; i++) {
uint8_t c = static_cast<uint8_t>(firstLine[i]);
char letter = 0;
if (c < 0x80) {
if (c >= 'A' && c <= 'Z') letter = static_cast<char>(c + 32);
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) letter = static_cast<char>(c);
} else if (c == 0xC3 && i + 1 < firstLine.size()) { // U+00C0..U+00FF
letter = kPlain[static_cast<uint8_t>(firstLine[++i]) & 0x3F];
} else {
while (i + 1 < firstLine.size() && continuation(firstLine[i + 1])) i++; // anything else is left out
}
if (letter) {
if (dash && !out.empty()) out += '-';
dash = false;
out += letter;
} else {
dash = true;
}
}
return out.empty() ? "note-" + stamp : out;
}
std::string titleFrom(const std::string& head, size_t maxChars) {
for (size_t at = 0; at < head.size();) {
size_t end = head.find('\n', at);
bool cut = end == std::string::npos; // the line goes on past what was read
if (cut) end = head.size();
std::string line = head.substr(at, end - at);
if (cut) dropPartial(line);
while (!line.empty() && (line.back() == '\r' || line.back() == ' ' || line.back() == '\t')) line.pop_back();
size_t first = line.find_first_not_of(" \t");
if (first != std::string::npos) {
line.erase(0, first);
size_t bytes = 0;
for (size_t chars = 0; bytes < line.size() && chars < maxChars; chars++) {
bytes++;
while (bytes < line.size() && continuation(line[bytes])) bytes++;
}
line.resize(bytes);
return line;
}
at = end + 1;
}
return "";
}
} // namespace roro::notes
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace roro::notes {
// The text of a note while it's edited (F1, Q144, Q145): UTF-8 held whole in memory, a cursor, and
// the part of it on screen. Lines wrap at spaces, `cols` characters wide; a line owns the space or
// the newline it ends with, so every byte of the text belongs to exactly one line. No index of
// lines is kept (a note of newlines alone would need twice its size): where a line starts is
// worked out from the start of its paragraph, which is never far.
class NoteText {
public:
static constexpr size_t kMaxBytes = 16 * 1024;
// Room for a full note is reserved once, so typing never has to find a bigger block of memory:
// on the device a failed allocation is the end. The second form takes over a string the
// caller filled (and reserved): a note is never in memory twice.
NoteText(int cols, int rows);
NoteText(int cols, int rows, std::string&& text);
// Copied into the buffer already held. CRLF becomes LF (Q148). False, and nothing changes, if
// it's over kMaxBytes.
bool setText(const std::string& text);
const std::string& text() const { return text_; }
size_t cursor() const { return cursor_; }
uint32_t revision() const { return revision_; } // changes with every edit: is it saved?
bool insert(uint32_t codePoint); // false: the note is full
bool insertText(const std::string& s); // all of it or nothing
void backspace();
void left();
void right();
void up();
void down();
void pageUp();
void pageDown();
void lineStart();
void lineEnd();
void toStart();
void toEnd();
// The screen, kept around the cursor: its rows (tabs as spaces, the ending newline left out),
// and where the cursor is on it, in rows and characters.
std::vector<std::string> rows();
int cursorRow();
int cursorCol();
int percent() const { return text_.empty() ? 0 : static_cast<int>(top_ * 100 / text_.size()); }
std::string firstLine() const; // without its newline, for the title and the file's name
private:
size_t nextLine(size_t start) const; // where the line after the one at `start` starts
size_t lineOf(size_t pos) const; // the start of the line `pos` is on
size_t lastSpot(size_t start) const; // the last place the cursor can be on that line
size_t atColumn(size_t start, int col) const;
int columnOf(size_t start, size_t pos) const;
bool hasLineAfter(size_t start) const;
void moved(bool keepGoal = false);
void follow(); // scrolls so the cursor is on screen
void dropCarriageReturns();
int cols_, rows_;
std::string text_;
size_t cursor_ = 0, top_ = 0;
int goal_ = -1; // the column Up and Down aim for, across short lines
uint32_t revision_ = 0;
};
// The file a new note is saved as (Q142): from its first line, "Shopping list!" -> "shopping-list",
// or "note-<stamp>" when that gives nothing. No extension, no folder.
std::string nameFromFirstLine(const std::string& firstLine, const std::string& stamp);
// A row's title in the Notes list, from the first bytes of a file: its first line that isn't
// blank, cut to `maxChars`; "" if there's none.
std::string titleFrom(const std::string& head, size_t maxChars);
} // namespace roro::notes
+4 -1
View File
@@ -10,7 +10,10 @@ bool PowerPolicy::activity(uint32_t nowMs) {
}
ScreenState PowerPolicy::update(uint32_t nowMs) {
uint32_t idle = nowMs - lastActivityMs_;
// Activity stamped from a clock read after this pass's nowMs (the Debug Console's `key`) is in
// the future, not 49 days ago: unsigned, the screen went off for a tick and ate the next key.
int32_t since = static_cast<int32_t>(nowMs - lastActivityMs_);
uint32_t idle = since < 0 ? 0 : static_cast<uint32_t>(since);
state_ = idle >= offMs_ ? ScreenState::Off : idle >= dimMs_ ? ScreenState::Dimmed : ScreenState::On;
if (notifying_) {
if (static_cast<int32_t>(nowMs - notifyUntilMs_) >= 0)
+8 -3
View File
@@ -224,10 +224,15 @@ def interactive(sock):
sys.stdout.write(data.decode(errors="replace"))
sys.stdout.flush()
if sys.stdin in ready:
line = sys.stdin.readline()
if not line:
# Straight from the descriptor: readline() takes every waiting line into Python's own
# buffer and hands over one, and select() then sees nothing more to read. Piped input
# written while we were still connecting got stuck until the next line came.
data = os.read(sys.stdin.fileno(), 4096)
if not data:
return
sock.sendall(line.encode())
sock.setblocking(True)
sock.sendall(data)
sock.setblocking(False)
def main():
+13 -1
View File
@@ -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
+8
View File
@@ -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
+303
View File
@@ -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
+60
View File
@@ -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
+346
View File
@@ -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
+71
View File
@@ -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
+31 -1
View File
@@ -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);
+6 -4
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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);
+5
View File
@@ -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();
+288
View File
@@ -0,0 +1,288 @@
#include <unity.h>
#include <string>
#include <vector>
#include "note_text.h"
using namespace roro::notes;
void setUp() {}
void tearDown() {}
static void type(NoteText& t, const std::string& s) {
for (char c : s) TEST_ASSERT_TRUE(t.insert(static_cast<uint8_t>(c)));
}
void test_typing_and_rows() {
NoteText t(10, 4);
TEST_ASSERT_EQUAL_size_t(1, t.rows().size()); // an empty note has one empty row
type(t, "Hello\nworld");
auto rows = t.rows();
TEST_ASSERT_EQUAL_size_t(2, rows.size());
TEST_ASSERT_EQUAL_STRING("Hello", rows[0].c_str());
TEST_ASSERT_EQUAL_STRING("world", rows[1].c_str());
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
TEST_ASSERT_EQUAL_INT(5, t.cursorCol());
t.insert('\n');
TEST_ASSERT_EQUAL_size_t(3, t.rows().size()); // the empty line the cursor is on
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
t.backspace();
t.backspace();
TEST_ASSERT_EQUAL_STRING("Hello\nworl", t.text().c_str());
TEST_ASSERT_EQUAL_STRING("Hello", t.firstLine().c_str());
}
void test_wrapping_at_spaces() {
NoteText t(10, 6);
type(t, "one two three four five");
auto rows = t.rows();
TEST_ASSERT_EQUAL_size_t(3, rows.size());
TEST_ASSERT_EQUAL_STRING("one two ", rows[0].c_str()); // a line owns the space it ends with
TEST_ASSERT_EQUAL_STRING("three four ", rows[1].c_str()); // ten characters and that space
TEST_ASSERT_EQUAL_STRING("five", rows[2].c_str());
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
TEST_ASSERT_EQUAL_INT(4, t.cursorCol());
// Every byte is on exactly one row.
std::string all;
for (auto& r : rows) all += r;
TEST_ASSERT_EQUAL_STRING(t.text().c_str(), all.c_str());
}
void test_a_long_word_is_cut() {
NoteText t(5, 6);
type(t, "abcdefghijkl");
auto rows = t.rows();
TEST_ASSERT_EQUAL_size_t(3, rows.size());
TEST_ASSERT_EQUAL_STRING("abcde", rows[0].c_str());
TEST_ASSERT_EQUAL_STRING("fghij", rows[1].c_str());
TEST_ASSERT_EQUAL_STRING("kl", rows[2].c_str());
}
void test_left_right_over_accents() {
NoteText t(10, 4);
t.insert(0xE9); // é
t.insert('t');
t.insert(0xE9);
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t\xC3\xA9", t.text().c_str());
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
t.left();
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
t.left();
t.left();
t.left(); // already at the start
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
t.right();
TEST_ASSERT_EQUAL_size_t(2, t.cursor());
t.backspace();
TEST_ASSERT_EQUAL_STRING("t\xC3\xA9", t.text().c_str());
t.insert(0x20AC); // €, three bytes
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
t.backspace();
TEST_ASSERT_EQUAL_STRING("t\xC3\xA9", t.text().c_str());
}
void test_up_and_down_keep_their_column() {
NoteText t(20, 6);
TEST_ASSERT_TRUE(t.setText("a long first line\nab\n\nanother long line"));
t.lineEnd();
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
t.down(); // a short line: as far right as it goes
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
TEST_ASSERT_EQUAL_INT(2, t.cursorCol());
t.down(); // an empty one
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
t.down(); // and back out to the column it came from
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
t.down(); // the last line: stays
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
t.up();
t.up();
t.up();
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
TEST_ASSERT_EQUAL_INT(17, t.cursorCol());
t.up(); // the first line: stays
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
t.left(); // moving sideways forgets the column
t.down();
TEST_ASSERT_EQUAL_INT(2, t.cursorCol());
t.down();
t.down();
TEST_ASSERT_EQUAL_INT(16, t.cursorCol());
}
void test_up_and_down_through_wrapped_lines() {
NoteText t(10, 6);
TEST_ASSERT_TRUE(t.setText("one two three four five\nend"));
// "one two " / "three four " / "five" / "end"
t.toEnd();
TEST_ASSERT_EQUAL_INT(3, t.cursorRow());
t.up();
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
TEST_ASSERT_EQUAL_INT(3, t.cursorCol());
t.up();
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
t.lineEnd(); // on the space the line ends with, not past it
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
TEST_ASSERT_EQUAL_INT(10, t.cursorCol());
t.right(); // one more is the start of the next line
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
t.lineStart();
t.left();
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
t.lineStart();
TEST_ASSERT_EQUAL_INT(0, t.cursorCol());
TEST_ASSERT_EQUAL_size_t(8, t.cursor());
}
void test_a_final_newline_gives_a_last_empty_line() {
NoteText t(10, 6);
TEST_ASSERT_TRUE(t.setText("ab\n"));
TEST_ASSERT_EQUAL_size_t(2, t.rows().size());
t.down();
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
TEST_ASSERT_EQUAL_INT(1, t.cursorRow());
t.down();
TEST_ASSERT_EQUAL_size_t(3, t.cursor());
t.up();
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
t.lineEnd();
TEST_ASSERT_EQUAL_size_t(2, t.cursor());
}
void test_the_screen_follows_the_cursor() {
NoteText t(10, 3);
std::string text;
for (int i = 0; i < 20; i++) text += "line " + std::to_string(i) + "\n";
TEST_ASSERT_TRUE(t.setText(text));
TEST_ASSERT_EQUAL_STRING("line 0", t.rows()[0].c_str());
for (int i = 0; i < 5; i++) t.down();
auto rows = t.rows();
TEST_ASSERT_EQUAL_STRING("line 3", rows[0].c_str()); // scrolled a line at a time
TEST_ASSERT_EQUAL_STRING("line 5", rows[2].c_str());
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
t.up();
t.up();
t.up();
TEST_ASSERT_EQUAL_STRING("line 2", t.rows()[0].c_str());
TEST_ASSERT_EQUAL_INT(0, t.cursorRow());
t.toEnd();
rows = t.rows();
TEST_ASSERT_EQUAL_size_t(3, rows.size());
TEST_ASSERT_EQUAL_STRING("line 18", rows[0].c_str());
TEST_ASSERT_EQUAL_STRING("", rows[2].c_str());
TEST_ASSERT_EQUAL_INT(2, t.cursorRow());
t.toStart();
TEST_ASSERT_EQUAL_STRING("line 0", t.rows()[0].c_str());
t.pageDown();
TEST_ASSERT_EQUAL_size_t(14, t.cursor()); // two lines down
t.pageUp();
TEST_ASSERT_EQUAL_size_t(0, t.cursor());
TEST_ASSERT_TRUE(t.percent() == 0);
}
// Deleting above the screen must not leave the screen starting in the middle of a line.
void test_edits_keep_the_screen_on_line_starts() {
NoteText t(10, 3);
TEST_ASSERT_TRUE(t.setText("aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj"));
t.toEnd();
t.rows();
for (int i = 0; i < 30; i++) {
t.backspace();
auto rows = t.rows();
TEST_ASSERT_TRUE(rows.size() <= 3);
int row = t.cursorRow();
TEST_ASSERT_TRUE(row >= 0 && row < static_cast<int>(rows.size()));
std::string all;
for (auto& r : rows) all += r;
TEST_ASSERT_TRUE(t.text().size() >= all.size());
TEST_ASSERT_EQUAL_STRING(t.text().substr(t.text().size() - all.size()).c_str(), all.c_str());
}
}
void test_full_and_too_big() {
NoteText t(38, 8);
TEST_ASSERT_FALSE(t.setText(std::string(NoteText::kMaxBytes + 1, 'x')));
TEST_ASSERT_TRUE(t.setText(std::string(NoteText::kMaxBytes - 1, 'x')));
uint32_t before = t.revision();
t.toEnd();
TEST_ASSERT_FALSE(t.insert(0xE9)); // two bytes: one too many
TEST_ASSERT_FALSE(t.insertText(" "));
TEST_ASSERT_EQUAL_UINT32(before, t.revision());
TEST_ASSERT_TRUE(t.insert('y'));
TEST_ASSERT_FALSE(t.insert('z'));
TEST_ASSERT_TRUE(t.revision() != before);
// One paragraph of 16 KB: moving through it still works.
t.up();
t.up();
t.lineStart();
TEST_ASSERT_EQUAL_size_t((NoteText::kMaxBytes / 38 - 2) * 38, t.cursor()); // two rows of 38 above the last
}
void test_crlf_becomes_lf() {
NoteText t(38, 8);
TEST_ASSERT_TRUE(t.setText("a\r\nb\r\n\rc"));
TEST_ASSERT_EQUAL_STRING("a\nb\n\rc", t.text().c_str());
}
// The device hands over the string it read the file into: no second copy, and never a bigger
// buffer afterwards, whatever is typed.
void test_adopting_a_buffer_never_reallocates() {
std::string body;
body.reserve(NoteText::kMaxBytes);
body = "one\r\ntwo\r\n";
const char* buffer = body.data();
NoteText t(38, 8, std::move(body));
TEST_ASSERT_EQUAL_STRING("one\ntwo\n", t.text().c_str());
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
t.toEnd();
while (t.insert('x')) {}
TEST_ASSERT_EQUAL_size_t(NoteText::kMaxBytes, t.text().size());
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
TEST_ASSERT_TRUE(t.setText("replaced"));
TEST_ASSERT_EQUAL_PTR(buffer, t.text().data());
NoteText fresh(38, 8);
const char* own = fresh.text().data();
while (fresh.insert('y')) {}
TEST_ASSERT_EQUAL_PTR(own, fresh.text().data());
}
void test_names() {
TEST_ASSERT_EQUAL_STRING("shopping-list", nameFromFirstLine("Shopping list!", "x").c_str());
TEST_ASSERT_EQUAL_STRING("ete-a-la-plage-2026", nameFromFirstLine(" \xC3\x89t\xC3\xA9 \xC3\xA0 la plage (2026)", "x").c_str());
TEST_ASSERT_EQUAL_STRING("note-20261006-0919", nameFromFirstLine("", "20261006-0919").c_str());
TEST_ASSERT_EQUAL_STRING("note-20261006-0919", nameFromFirstLine("\xE2\x82\xAC ?!", "20261006-0919").c_str());
TEST_ASSERT_TRUE(nameFromFirstLine(std::string(100, 'a'), "x").size() <= 33);
TEST_ASSERT_EQUAL_STRING("strase", nameFromFirstLine("Stra\xC3\x9F" "e", "x").c_str());
}
void test_titles() {
TEST_ASSERT_EQUAL_STRING("Shopping list", titleFrom("\n \r\n Shopping list \r\nmilk\n", 24).c_str());
TEST_ASSERT_EQUAL_STRING("abcde", titleFrom("abcdefgh", 5).c_str());
TEST_ASSERT_EQUAL_STRING("\xC3\xA9t", titleFrom("\xC3\xA9t\xC3", 24).c_str()); // a character cut by the end of what was read
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", titleFrom("\xC3\xA9t\xC3\xA9", 1).c_str());
TEST_ASSERT_EQUAL_STRING("", titleFrom(" \n\n", 24).c_str());
TEST_ASSERT_EQUAL_STRING("", titleFrom("", 24).c_str());
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_typing_and_rows);
RUN_TEST(test_wrapping_at_spaces);
RUN_TEST(test_a_long_word_is_cut);
RUN_TEST(test_left_right_over_accents);
RUN_TEST(test_up_and_down_keep_their_column);
RUN_TEST(test_up_and_down_through_wrapped_lines);
RUN_TEST(test_a_final_newline_gives_a_last_empty_line);
RUN_TEST(test_the_screen_follows_the_cursor);
RUN_TEST(test_edits_keep_the_screen_on_line_starts);
RUN_TEST(test_full_and_too_big);
RUN_TEST(test_crlf_becomes_lf);
RUN_TEST(test_adopting_a_buffer_never_reallocates);
RUN_TEST(test_names);
RUN_TEST(test_titles);
return UNITY_END();
}
+10
View File
@@ -31,6 +31,15 @@ void test_key_on_off_screen_only_wakes() {
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(70001)));
}
// A key stamped a few milliseconds after the time the same pass updates with must not read as
// 49 days of idleness: the screen stays on, and the next key acts.
void test_activity_stamped_after_the_pass_time_keeps_the_screen_on() {
PowerPolicy p(30000, 60000);
TEST_ASSERT_FALSE(p.activity(10003));
TEST_ASSERT_TRUE(p.update(10000) == ScreenState::On);
TEST_ASSERT_FALSE(p.activity(10050));
}
void test_new_timeouts_apply_immediately() {
PowerPolicy p(30000, 60000);
p.activity(0);
@@ -91,6 +100,7 @@ int main() {
RUN_TEST(test_screen_dims_then_turns_off_after_inactivity);
RUN_TEST(test_key_on_dimmed_screen_wakes_and_still_acts);
RUN_TEST(test_key_on_off_screen_only_wakes);
RUN_TEST(test_activity_stamped_after_the_pass_time_keeps_the_screen_on);
RUN_TEST(test_new_timeouts_apply_immediately);
RUN_TEST(test_notification_lights_an_off_screen_dimmed_until_it_ends);
RUN_TEST(test_notification_does_not_count_as_activity);