Files
roro9stack/lib/notes/src/note_text.cpp
T
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

330 lines
10 KiB
C++

#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