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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user