Public Access
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
330 lines
10 KiB
C++
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
|