Files

1414 lines
42 KiB
C++

#pragma once
#include <helpers/ui/UIScreen.h>
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "../NodePrefs.h"
// Forward declarations
class UITask;
// ============================================================================
// Configuration
// ============================================================================
#define NOTES_FOLDER "/notes"
#define NOTES_MAX_FILES 30
#define NOTES_BUF_SIZE 16384 // 16KB buffer (PSRAM-backed)
#define NOTES_FILENAME_MAX 40
#define NOTES_RENAME_MAX 32 // Max rename buffer length
#define NOTES_MAX_LINES 1024 // Max visual lines for cursor nav
// ============================================================================
// NotesScreen - Create, view, and edit .txt notes on SD card
// ============================================================================
// Modes:
// FILE_LIST - Browse existing notes, create new ones
// READING - Paginated read-only view of a note
// EDITING - Full text editor with cursor navigation
// RENAMING - Rename a file (inline text input)
// CONFIRM_DELETE - Delete confirmation dialog
//
// Key bindings:
// FILE_LIST: W/S = scroll, Enter = open note (read), Q = exit
// Shift+Enter = rename selected file
// Shift+Backspace = delete selected file (with confirmation)
// READING: W/S = page nav, Enter = switch to edit, Q = back to list
// Shift+Backspace = delete note
// EDITING: Type = insert at cursor, Backspace = delete before cursor
// Enter = newline, Shift+WASD = cursor navigation
// Shift+Backspace = save & exit
// RENAMING: Type = edit filename, Backspace = delete char
// Enter = confirm rename, Q = cancel
// CONFIRM_DELETE: Enter = confirm delete, Q = cancel
//
// Filenames: RTC timestamp (note_YYYYMMDD_HHMM.txt) or sequential (note_001.txt)
// Buffer: 16KB on PSRAM for longer notes
// ============================================================================
class NotesScreen : public UIScreen {
public:
enum Mode { FILE_LIST, READING, EDITING, RENAMING, CONFIRM_DELETE };
private:
UITask* _task;
NodePrefs* _prefs;
Mode _mode;
bool _sdReady;
bool _initialized;
uint8_t _lastFontPref;
DisplayDriver* _display;
// Display layout (calculated once from display metrics)
int _charsPerLine;
int _linesPerPage;
int _lineHeight;
int _footerHeight;
// Editor-specific layout
int _editCharsPerLine;
int _editLineHeight;
int _editMaxLines; // Max visible lines in editor area
// File list state
std::vector<String> _fileList;
int _selectedFile; // 0 = "+ New Note", 1..N = existing files
// Current note state
String _currentFile; // Filename (just name, not full path)
char* _buf; // Note content buffer (PSRAM-backed)
int _bufLen; // Current content length
int _cursorPos; // Cursor byte position in buffer
bool _dirty; // Has unsaved changes
// Reading state (paginated view)
int _currentPage;
int _totalPages;
std::vector<int> _pageOffsets;
// Editor visual lines (rebuilt on content/cursor change)
struct EditorLine { int start; int end; };
EditorLine* _editorLines;
int _numEditorLines;
int _editorScrollTop; // First visible line index
// Rename state
char _renameBuf[NOTES_RENAME_MAX];
int _renameLen;
String _renameOriginal; // Original filename (for cancel)
// Delete confirmation state
String _deleteTarget; // File to delete
// RTC timestamp support
uint32_t _rtcTime; // Unix timestamp (0 = unavailable)
int8_t _utcOffset; // UTC offset in hours
// Callback to get fresh RTC time (set by UITask at init)
typedef uint32_t (*TimeGetterFn)();
TimeGetterFn _getTimeFn = nullptr;
// ---- Helpers ----
String getFullPath(const String& filename) {
return String(NOTES_FOLDER) + "/" + filename;
}
// Generate filename using RTC timestamp or sequential fallback
String generateFilename() {
// Try RTC-based name first
if (_rtcTime > 1700000000) {
time_t t = (time_t)_rtcTime + ((int32_t)_utcOffset * 3600);
struct tm* tm = gmtime(&t);
if (tm) {
char name[NOTES_FILENAME_MAX];
snprintf(name, sizeof(name), "note_%04d%02d%02d_%02d%02d.txt",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
tm->tm_hour, tm->tm_min);
// Check for collision (two notes in same minute)
String fullPath = getFullPath(String(name));
if (!SD.exists(fullPath.c_str())) {
return String(name);
}
// Append seconds to disambiguate
snprintf(name, sizeof(name), "note_%04d%02d%02d_%02d%02d%02d.txt",
tm->tm_year + 1900, tm->tm_mon + 1, tm->tm_mday,
tm->tm_hour, tm->tm_min, tm->tm_sec);
return String(name);
}
}
// Fallback: sequential numbering
int maxNum = 0;
for (int i = 0; i < (int)_fileList.size(); i++) {
const String& name = _fileList[i];
if (name.startsWith("note_") && name.endsWith(".txt")) {
String numPart = name.substring(5, name.length() - 4);
int num = numPart.toInt();
if (num > maxNum) maxNum = num;
}
}
char name[NOTES_FILENAME_MAX];
snprintf(name, sizeof(name), "note_%03d.txt", maxNum + 1);
return String(name);
}
// ---- File Scanning ----
void scanFiles() {
_fileList.clear();
if (!SD.exists(NOTES_FOLDER)) {
SD.mkdir(NOTES_FOLDER);
Serial.printf("Notes: Created %s\n", NOTES_FOLDER);
}
File root = SD.open(NOTES_FOLDER);
if (!root || !root.isDirectory()) return;
File f = root.openNextFile();
while (f && _fileList.size() < NOTES_MAX_FILES) {
if (!f.isDirectory()) {
String name = String(f.name());
int slash = name.lastIndexOf('/');
if (slash >= 0) name = name.substring(slash + 1);
if (!name.startsWith(".") &&
(name.endsWith(".txt") || name.endsWith(".TXT"))) {
_fileList.push_back(name);
}
}
f = root.openNextFile();
}
root.close();
// Sort alphabetically
for (int i = 0; i < (int)_fileList.size() - 1; i++) {
for (int j = i + 1; j < (int)_fileList.size(); j++) {
if (_fileList[i] > _fileList[j]) {
String tmp = _fileList[i];
_fileList[i] = _fileList[j];
_fileList[j] = tmp;
}
}
}
Serial.printf("Notes: Found %d files\n", _fileList.size());
}
// ---- File I/O ----
bool loadNote(const String& filename) {
String path = getFullPath(filename);
File file = SD.open(path.c_str(), FILE_READ);
if (!file) {
Serial.printf("Notes: Failed to open %s\n", filename.c_str());
return false;
}
unsigned long size = file.size();
int toRead = min((unsigned long)(NOTES_BUF_SIZE - 1), size);
_bufLen = file.readBytes(_buf, toRead);
_buf[_bufLen] = '\0';
file.close();
digitalWrite(SDCARD_CS, HIGH);
_currentFile = filename;
_cursorPos = _bufLen;
_dirty = false;
if (size >= NOTES_BUF_SIZE) {
Serial.printf("Notes: Warning - %s truncated (%lu > %d)\n",
filename.c_str(), size, NOTES_BUF_SIZE - 1);
}
Serial.printf("Notes: Loaded %s (%d bytes)\n", filename.c_str(), _bufLen);
return true;
}
bool saveNote() {
if (_currentFile.length() == 0) return false;
String path = getFullPath(_currentFile);
if (SD.exists(path.c_str())) {
SD.remove(path.c_str());
}
File file = SD.open(path.c_str(), FILE_WRITE);
if (!file) {
Serial.printf("Notes: Failed to save %s\n", _currentFile.c_str());
return false;
}
file.write((uint8_t*)_buf, _bufLen);
file.close();
digitalWrite(SDCARD_CS, HIGH);
_dirty = false;
Serial.printf("Notes: Saved %s (%d bytes)\n", _currentFile.c_str(), _bufLen);
return true;
}
bool deleteNote(const String& filename) {
String path = getFullPath(filename);
if (SD.exists(path.c_str())) {
SD.remove(path.c_str());
Serial.printf("Notes: Deleted %s\n", filename.c_str());
return true;
}
return false;
}
bool renameNote(const String& oldName, const String& newName) {
String oldPath = getFullPath(oldName);
String newPath = getFullPath(newName);
if (!SD.exists(oldPath.c_str())) return false;
if (SD.exists(newPath.c_str())) {
Serial.printf("Notes: Rename failed - %s already exists\n", newName.c_str());
return false;
}
// SD library doesn't have rename, so copy + delete
File src = SD.open(oldPath.c_str(), FILE_READ);
if (!src) return false;
File dst = SD.open(newPath.c_str(), FILE_WRITE);
if (!dst) { src.close(); return false; }
uint8_t copyBuf[512];
while (src.available()) {
int n = src.read(copyBuf, sizeof(copyBuf));
if (n > 0) dst.write(copyBuf, n);
}
dst.close();
src.close();
SD.remove(oldPath.c_str());
digitalWrite(SDCARD_CS, HIGH);
Serial.printf("Notes: Renamed %s -> %s\n", oldName.c_str(), newName.c_str());
return true;
}
// ---- Pagination for Read Mode ----
void buildPageIndex() {
_pageOffsets.clear();
_pageOffsets.push_back(0);
int pos = 0;
int lineCount = 0;
while (pos < _bufLen) {
int lineEnd = pos;
int nextStart = pos;
int charCount = 0;
int lastBreak = -1;
bool inWord = false;
for (int i = pos; i < _bufLen; i++) {
char c = _buf[i];
if (c == '\n') { lineEnd = i; nextStart = i + 1; goto pageLineFound; }
if (c == '\r') {
lineEnd = i; nextStart = i + 1;
if (nextStart < _bufLen && _buf[nextStart] == '\n') nextStart++;
goto pageLineFound;
}
if (c >= 32) {
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') { if (inWord) { lastBreak = i; inWord = false; } }
else if (c == '-') { if (inWord) lastBreak = i + 1; }
else inWord = true;
if (charCount >= _charsPerLine) {
if (lastBreak > pos) {
lineEnd = lastBreak; nextStart = lastBreak;
while (nextStart < _bufLen && (_buf[nextStart] == ' ' || _buf[nextStart] == '\t'))
nextStart++;
} else { lineEnd = i; nextStart = i; }
goto pageLineFound;
}
}
}
break;
pageLineFound:
lineCount++;
pos = nextStart;
if (lineCount >= _linesPerPage) {
_pageOffsets.push_back(pos);
lineCount = 0;
}
if (pos >= _bufLen) break;
}
_totalPages = _pageOffsets.size();
if (_currentPage >= _totalPages) {
_currentPage = max(0, _totalPages - 1);
}
}
// ---- Editor Line Building (for cursor navigation) ----
void buildEditorLines() {
_numEditorLines = 0;
int pos = 0;
while (pos < _bufLen && _numEditorLines < NOTES_MAX_LINES) {
_editorLines[_numEditorLines].start = pos;
int lineEnd = pos;
int nextStart = pos;
int charCount = 0;
int lastBreak = -1;
bool inWord = false;
for (int i = pos; i < _bufLen; i++) {
char c = _buf[i];
if (c == '\n') { lineEnd = i; nextStart = i + 1; goto edLineFound; }
if (c == '\r') {
lineEnd = i; nextStart = i + 1;
if (nextStart < _bufLen && _buf[nextStart] == '\n') nextStart++;
goto edLineFound;
}
if (c >= 32) {
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') { if (inWord) { lastBreak = i; inWord = false; } }
else if (c == '-') { if (inWord) lastBreak = i + 1; }
else inWord = true;
if (charCount >= _editCharsPerLine) {
if (lastBreak > pos) {
lineEnd = lastBreak; nextStart = lastBreak;
while (nextStart < _bufLen && (_buf[nextStart] == ' ' || _buf[nextStart] == '\t'))
nextStart++;
} else { lineEnd = i; nextStart = i; }
goto edLineFound;
}
}
}
lineEnd = _bufLen;
nextStart = _bufLen;
edLineFound:
_editorLines[_numEditorLines].end = lineEnd;
_numEditorLines++;
pos = nextStart;
if (pos >= _bufLen) break;
}
// Ensure at least one line (empty buffer)
if (_numEditorLines == 0) {
_editorLines[0] = {0, 0};
_numEditorLines = 1;
}
}
// Find which editor line contains a buffer position
int lineForPos(int bufPos) {
for (int i = 0; i < _numEditorLines; i++) {
int nextStart = (i + 1 < _numEditorLines) ? _editorLines[i + 1].start : _bufLen + 1;
if (bufPos >= _editorLines[i].start && bufPos < nextStart) return i;
}
return max(0, _numEditorLines - 1);
}
// Count visual columns from line start to a buffer position
int colForPos(int bufPos, int lineStart) {
int col = 0;
for (int i = lineStart; i < bufPos && i < _bufLen; i++) {
uint8_t b = (uint8_t)_buf[i];
if (b >= 0x80 && b < 0xC0) continue;
if (b == '\n' || b == '\r') break;
col++;
}
return col;
}
// Find buffer position for a target column on a given line
int posForCol(int targetCol, int lineIdx) {
if (lineIdx < 0 || lineIdx >= _numEditorLines) return _bufLen;
int start = _editorLines[lineIdx].start;
int end = _editorLines[lineIdx].end;
int col = 0;
for (int i = start; i < end && i < _bufLen; i++) {
uint8_t b = (uint8_t)_buf[i];
if (b >= 0x80 && b < 0xC0) continue;
if (b == '\n' || b == '\r') return i;
if (col >= targetCol) return i;
col++;
}
return end;
}
// Ensure the editor scroll position keeps cursor visible
void ensureCursorVisible() {
int cursorLine = lineForPos(_cursorPos);
if (cursorLine < _editorScrollTop) {
_editorScrollTop = cursorLine;
}
if (cursorLine >= _editorScrollTop + _editMaxLines) {
_editorScrollTop = cursorLine - _editMaxLines + 1;
}
if (_editorScrollTop < 0) _editorScrollTop = 0;
}
// ---- Cursor Operations ----
void insertAtCursor(char c) {
if (_bufLen >= NOTES_BUF_SIZE - 1) return;
memmove(&_buf[_cursorPos + 1], &_buf[_cursorPos], _bufLen - _cursorPos);
_buf[_cursorPos] = c;
_bufLen++;
_buf[_bufLen] = '\0';
_cursorPos++;
_dirty = true;
}
void deleteBeforeCursor() {
if (_cursorPos <= 0) return;
memmove(&_buf[_cursorPos - 1], &_buf[_cursorPos], _bufLen - _cursorPos);
_cursorPos--;
_bufLen--;
_buf[_bufLen] = '\0';
_dirty = true;
}
// ---- Rendering ----
void drawBriefSplash(const char* message) {
if (!_display) return;
_display->startFrame();
_display->setTextSize(2);
_display->setColor(DisplayDriver::GREEN);
_display->setCursor(10, 40);
_display->print(message);
_display->setTextSize(1);
_display->endFrame();
}
void renderFileList(DisplayDriver& display) {
char tmp[40];
// Header
display.setCursor(0, 0);
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.print("Notes");
// Right side of header: [R:Rename] [count]
snprintf(tmp, sizeof(tmp), "[%d]", (int)_fileList.size());
int rightX = display.width() - display.getTextWidth(tmp) - 2;
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* hint = "[Hold:Rename]";
#else
const char* hint = "[R:Rename]";
#endif
int hintX = rightX - display.getTextWidth(hint) - 4;
display.setCursor(hintX, 0);
display.setColor(DisplayDriver::YELLOW);
display.print(hint);
}
display.setCursor(rightX, 0);
display.setColor(DisplayDriver::GREEN);
display.print(tmp);
display.drawRect(0, 11, display.width(), 1);
// File list with "+ New Note" at index 0
display.setTextSize(_prefs->smallTextSize());
int listLineH = _prefs->smallLineH();
int startY = 14;
int totalItems = 1 + (int)_fileList.size();
int maxVisible = (display.height() - startY - _footerHeight) / listLineH;
if (maxVisible < 3) maxVisible = 3;
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
totalItems - maxVisible));
int endIdx = min(totalItems, startIdx + maxVisible);
int y = startY;
for (int i = startIdx; i < endIdx; i++) {
bool selected = (i == _selectedFile);
if (selected) {
display.setColor(DisplayDriver::LIGHT);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.fillRect(0, y, display.width(), listLineH);
#else
display.fillRect(0, y + _prefs->smallHighlightOff(), display.width(), listLineH);
#endif
display.setColor(DisplayDriver::DARK);
} else {
display.setColor(DisplayDriver::LIGHT);
}
if (i == 0) {
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
display.drawTextEllipsized(0, y, display.width() - 4,
selected ? "> + New Note" : " + New Note");
} else {
String line = selected ? "> " : " ";
line += _fileList[i - 1];
display.drawTextEllipsized(0, y, display.width() - 4, line.c_str());
}
y += listLineH;
}
display.setTextSize(1);
// Footer
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Nav");
const char* right = "Tap:Open";
#else
display.print("Q:Bk");
const char* right = "Tap/Ent:Open";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
void renderReadPage(DisplayDriver& display) {
if (_totalPages == 0) {
display.setCursor(0, 14);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.print("(empty note)");
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Tap:Edit");
const char* right = "Hold:Delete";
#else
display.print("Q:Bk Ent:Edit");
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
return;
}
// Render current page using tiny font
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::LIGHT);
int pageStart = _pageOffsets[_currentPage];
int pageEnd = (_currentPage + 1 < _totalPages)
? _pageOffsets[_currentPage + 1]
: _bufLen;
int y = 0;
int lineCount = 0;
int pos = pageStart;
int maxY = display.height() - _footerHeight - _lineHeight;
while (pos < pageEnd && pos < _bufLen && lineCount < _linesPerPage && y <= maxY) {
int lineEnd = pos;
int nextStart = pos;
int charCount = 0;
int lastBreak = -1;
bool inWord = false;
for (int i = pos; i < pageEnd && i < _bufLen; i++) {
char c = _buf[i];
if (c == '\n') { lineEnd = i; nextStart = i + 1; goto renderLine; }
if (c == '\r') {
lineEnd = i; nextStart = i + 1;
if (nextStart < _bufLen && _buf[nextStart] == '\n') nextStart++;
goto renderLine;
}
if (c >= 32) {
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') { if (inWord) { lastBreak = i; inWord = false; } }
else if (c == '-') { if (inWord) lastBreak = i + 1; }
else inWord = true;
if (charCount >= _charsPerLine) {
if (lastBreak > pos) {
lineEnd = lastBreak; nextStart = lastBreak;
while (nextStart < _bufLen && (_buf[nextStart] == ' ' || _buf[nextStart] == '\t'))
nextStart++;
} else { lineEnd = i; nextStart = i; }
goto renderLine;
}
}
}
lineEnd = _bufLen;
nextStart = _bufLen;
renderLine:
display.setCursor(0, y);
char charStr[2] = {0, 0};
for (int j = pos; j < lineEnd && j < _bufLen;) {
uint8_t b = (uint8_t)_buf[j];
if (b < 32) { j++; continue; }
if (b >= 0x80) {
uint32_t cp = decodeUtf8Char(_buf, lineEnd, &j);
uint8_t glyph = unicodeToCP437(cp);
if (glyph) { charStr[0] = (char)glyph; display.print(charStr); }
} else {
charStr[0] = (char)b;
display.print(charStr);
j++;
}
}
y += _lineHeight;
lineCount++;
pos = nextStart;
if (pos >= pageEnd) break;
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Swipe:Page");
const char* right = "Tap:Edit";
#else
display.print("Q:Bk Ent:Edit");
const char* right = "X:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
void renderEditor(DisplayDriver& display) {
// Rebuild visual lines and ensure cursor is visible
buildEditorLines();
ensureCursorVisible();
// Header
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
char header[40];
String shortName = _currentFile;
if (shortName.length() > 18) {
shortName = shortName.substring(0, 15) + "...";
}
snprintf(header, sizeof(header), "Edit: %s%s",
shortName.c_str(), _dirty ? "*" : "");
display.print(header);
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Text area - tiny font (same as read mode)
int textAreaTop = 14;
int textAreaBottom = display.height() - 16;
display.setTextSize(_prefs->smallTextSize());
// Find cursor line
int cursorLine = lineForPos(_cursorPos);
// Render visible lines
int y = textAreaTop;
display.setColor(DisplayDriver::LIGHT);
for (int li = _editorScrollTop;
li < _numEditorLines && y < textAreaBottom;
li++) {
display.setCursor(0, y);
char charStr[2] = {0, 0};
int lineStart = _editorLines[li].start;
int lineEnd = _editorLines[li].end;
// Render characters, inserting cursor at the right position
bool cursorDrawn = false;
for (int j = lineStart; j < lineEnd && j < _bufLen; j++) {
// Draw cursor before this character if cursor is here
if (li == cursorLine && j == _cursorPos && !cursorDrawn) {
display.setColor(DisplayDriver::GREEN);
display.print("|");
display.setColor(DisplayDriver::LIGHT);
cursorDrawn = true;
}
uint8_t b = (uint8_t)_buf[j];
if (b < 32) continue;
charStr[0] = (char)b;
display.print(charStr);
}
// Cursor at end of this line
if (li == cursorLine && !cursorDrawn) {
display.setColor(DisplayDriver::GREEN);
display.print("|");
display.setColor(DisplayDriver::LIGHT);
}
y += _editLineHeight;
}
// If buffer is empty, show cursor at top
if (_bufLen == 0) {
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, textAreaTop);
display.print("|");
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
char status[20];
int curPage = (_editMaxLines > 0) ? (cursorLine / _editMaxLines) + 1 : 1;
int totalPg = (_editMaxLines > 0) ? max(1, (_numEditorLines + _editMaxLines - 1) / _editMaxLines) : 1;
snprintf(status, sizeof(status), "Pg %d/%d", curPage, totalPg);
display.print(status);
#if defined(LilyGo_T5S3_EPaper_Pro)
const char* mid = "Tap:Type";
display.setCursor((display.width() - display.getTextWidth(mid)) / 2, footerY);
display.print(mid);
#endif
const char* right;
if (_bufLen == 0 || !_dirty) {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Back";
#else
right = "Q:Back";
#endif
} else {
#if defined(LilyGo_T5S3_EPaper_Pro)
right = "Hold:Save";
#else
right = "Sh+Del:Save";
#endif
}
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
void renderRenameDialog(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("Rename Note");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Show original name
display.setCursor(0, 20);
display.setColor(DisplayDriver::LIGHT);
display.print("From: ");
display.setTextSize(_prefs->smallTextSize());
String origDisplay = _renameOriginal;
if (origDisplay.length() > 30) origDisplay = origDisplay.substring(0, 27) + "...";
display.print(origDisplay.c_str());
// Show editable name with cursor
display.setTextSize(1);
display.setCursor(0, 38);
display.setColor(DisplayDriver::LIGHT);
display.print("To: ");
display.setTextSize(_prefs->smallTextSize());
display.setColor(DisplayDriver::GREEN);
char displayName[NOTES_RENAME_MAX + 2];
snprintf(displayName, sizeof(displayName), "%s|", _renameBuf);
display.print(displayName);
// Show .txt extension hint
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.setCursor(0, 56);
display.print("(.txt added automatically)");
// Footer
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
const char* right = "Tap:Confirm";
#else
display.print("Q:Cancel");
const char* right = "Ent:Confirm";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
void renderDeleteConfirm(DisplayDriver& display) {
display.setTextSize(1);
display.setColor(DisplayDriver::GREEN);
display.setCursor(0, 0);
display.print("Delete Note?");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
display.setCursor(0, 25);
display.print("File:");
display.setTextSize(_prefs->smallTextSize());
display.setCursor(0, 38);
String nameDisplay = _deleteTarget;
if (nameDisplay.length() > 35) nameDisplay = nameDisplay.substring(0, 32) + "...";
display.print(nameDisplay.c_str());
display.setTextSize(1);
display.setCursor(0, 58);
display.setColor(DisplayDriver::GREEN);
display.print("This cannot be undone.");
// Footer
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
display.setCursor(0, footerY);
#if defined(LilyGo_T5S3_EPaper_Pro)
display.print("Boot:Cancel");
const char* right = "Tap:Delete";
#else
display.print("Q:Cancel");
const char* right = "Ent:Delete";
#endif
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
display.print(right);
}
// ---- Input Handling ----
bool handleFileListInput(char c) {
int totalItems = 1 + (int)_fileList.size();
// Shift+W: page up
if (c == 'W') {
int pageSize = (128 - 14 - 14) / _prefs->smallLineH();
if (pageSize < 3) pageSize = 3;
_selectedFile = max(0, _selectedFile - pageSize);
return true;
}
if (c == 'w' || c == 0xF2) {
if (_selectedFile > 0) { _selectedFile--; return true; }
return false;
}
// Shift+S: page down
if (c == 'S') {
int pageSize = (128 - 14 - 14) / _prefs->smallLineH();
if (pageSize < 3) pageSize = 3;
_selectedFile = min(totalItems - 1, _selectedFile + pageSize);
return true;
}
if (c == 's' || c == 0xF1) {
if (_selectedFile < totalItems - 1) { _selectedFile++; return true; }
return false;
}
// Enter - open selected item
if (c == '\r' || c == 13) {
if (_selectedFile == 0) {
createNewNote();
return true;
} else {
int fileIdx = _selectedFile - 1;
if (fileIdx >= 0 && fileIdx < (int)_fileList.size()) {
if (loadNote(_fileList[fileIdx])) {
buildPageIndex();
_currentPage = 0;
_mode = READING;
}
return true;
}
}
return false;
}
// R - rename selected file
if (c == 'r') {
if (_selectedFile >= 1 && _selectedFile <= (int)_fileList.size()) {
startRename();
return true;
}
return false;
}
return false;
}
bool handleReadInput(char c) {
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
if (_currentPage > 0) { _currentPage--; return true; }
return false;
}
if (c == 's' || c == 'S' || c == 'd' || c == 'D' || c == ' ' || c == 0xF1) {
if (_currentPage < _totalPages - 1) { _currentPage++; return true; }
return false;
}
// Enter - switch to edit mode
if (c == '\r' || c == 13) {
_cursorPos = _bufLen;
_editorScrollTop = 0;
_mode = EDITING;
Serial.printf("Notes: Editing %s (%d bytes)\n", _currentFile.c_str(), _bufLen);
return true;
}
if (c == 'q' || c == 'Q') {
_mode = FILE_LIST;
return true;
}
return false;
}
bool handleEditInput(char c) {
// Backspace - delete before cursor
if (c == '\b') {
deleteBeforeCursor();
return _dirty;
}
// Enter - insert newline at cursor
if (c == '\r' || c == 13) {
if (_bufLen < NOTES_BUF_SIZE - 2) {
insertAtCursor('\n');
return true;
}
return false;
}
// Regular printable character - insert at cursor
if (c >= 32 && c < 127 && _bufLen < NOTES_BUF_SIZE - 1) {
insertAtCursor(c);
return true;
}
return false;
}
bool handleRenameInput(char c) {
// Q - cancel rename
if (c == 'q' || c == 'Q') {
_mode = FILE_LIST;
Serial.println("Notes: Rename cancelled");
return true;
}
// Enter - confirm rename
if (c == '\r' || c == 13) {
if (_renameLen > 0) {
String newName = String(_renameBuf) + ".txt";
if (newName != _renameOriginal) {
drawBriefSplash("Renaming...");
if (renameNote(_renameOriginal, newName)) {
if (_currentFile == _renameOriginal) {
_currentFile = newName;
}
}
}
scanFiles();
}
_mode = FILE_LIST;
return true;
}
// Backspace
if (c == '\b') {
if (_renameLen > 0) {
_renameLen--;
_renameBuf[_renameLen] = '\0';
return true;
}
return false;
}
// Printable characters (restrict to filename-safe chars)
if (c >= 32 && c < 127 && _renameLen < NOTES_RENAME_MAX - 2) {
if (c == '/' || c == '\\' || c == ':' || c == '*' ||
c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
return false;
}
_renameBuf[_renameLen++] = c;
_renameBuf[_renameLen] = '\0';
return true;
}
return false;
}
bool handleDeleteConfirmInput(char c) {
// Enter - confirm delete
if (c == '\r' || c == 13) {
drawBriefSplash("Deleting...");
deleteNote(_deleteTarget);
_deleteTarget = "";
_selectedFile = 0;
_mode = FILE_LIST;
scanFiles();
return true;
}
// Q or backspace - cancel
if (c == 'q' || c == 'Q' || c == '\b') {
_deleteTarget = "";
_mode = FILE_LIST;
return true;
}
return false;
}
// ---- Note Creation ----
void createNewNote() {
// Refresh timestamp at creation time for accurate filenames
if (_getTimeFn) {
_rtcTime = _getTimeFn();
}
_currentFile = generateFilename();
_buf[0] = '\0';
_bufLen = 0;
_cursorPos = 0;
_editorScrollTop = 0;
_dirty = true;
_mode = EDITING;
Serial.printf("Notes: Created new note %s\n", _currentFile.c_str());
}
public:
NotesScreen(UITask* task, NodePrefs* prefs = nullptr)
: _task(task), _prefs(prefs), _mode(FILE_LIST),
_sdReady(false), _initialized(false), _lastFontPref(0), _display(nullptr),
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
_editCharsPerLine(20), _editLineHeight(12), _editMaxLines(8),
_selectedFile(0), _buf(nullptr), _bufLen(0), _cursorPos(0),
_dirty(false), _currentPage(0), _totalPages(0),
_editorLines(nullptr), _numEditorLines(0), _editorScrollTop(0),
_renameLen(0), _rtcTime(0), _utcOffset(0) {
// Allocate main buffer on PSRAM if available
#ifdef BOARD_HAS_PSRAM
_buf = (char*)ps_malloc(NOTES_BUF_SIZE);
#else
_buf = (char*)malloc(NOTES_BUF_SIZE);
#endif
if (_buf) _buf[0] = '\0';
else Serial.println("Notes: FATAL - buffer allocation failed!");
// Allocate editor lines array
#ifdef BOARD_HAS_PSRAM
_editorLines = (EditorLine*)ps_malloc(sizeof(EditorLine) * NOTES_MAX_LINES);
#else
_editorLines = (EditorLine*)malloc(sizeof(EditorLine) * NOTES_MAX_LINES);
#endif
_renameBuf[0] = '\0';
}
~NotesScreen() {
if (_buf) free(_buf);
if (_editorLines) free(_editorLines);
}
// ---- Layout Init ----
void initLayout(DisplayDriver& display) {
// Re-init if font preference changed since last layout
uint8_t curFont = _prefs ? _prefs->large_font : 0;
if (_initialized && curFont != _lastFontPref) {
_initialized = false;
Serial.println("Notes: font changed, recalculating layout");
}
if (_initialized) return;
_lastFontPref = curFont;
_display = &display;
// Font metrics (for read mode)
display.setTextSize(_prefs->smallTextSize());
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
if (tenCharsW > 0) {
_charsPerLine = (display.width() * 10) / tenCharsW;
}
// Proportional font: use average-width measurement instead of M-width
if (_prefs && _prefs->large_font) {
const char* sample = "the quick brown fox jumps over lazy dog";
uint16_t sampleW = display.getTextWidth(sample);
int sampleLen = strlen(sample);
if (sampleW > 0 && sampleLen > 0) {
_charsPerLine = (display.width() * sampleLen * 70) / ((int)sampleW * 100);
}
}
if (_charsPerLine < 15) _charsPerLine = 15;
if (_charsPerLine > 60) _charsPerLine = 60;
uint16_t mWidth = display.getTextWidth("M");
if (mWidth > 0) {
_lineHeight = max(3, (int)((mWidth * 7 * 12) / (6 * 10)));
} else {
_lineHeight = 5;
}
// Large font: formula above assumes built-in 6x8 ratio — too small for 9pt
if (_prefs && _prefs->large_font) {
_lineHeight = _prefs->smallLineH();
}
_footerHeight = 14;
int textAreaHeight = display.height() - _footerHeight;
_linesPerPage = textAreaHeight / _lineHeight;
if (_linesPerPage < 5) _linesPerPage = 5;
// Size 0 (tiny) font metrics for edit mode too (same font as read mode)
_editCharsPerLine = _charsPerLine;
_editLineHeight = _lineHeight;
int editTextAreaH = display.height() - 14 - 16; // Header + footer
_editMaxLines = editTextAreaH / _editLineHeight;
if (_editMaxLines < 3) _editMaxLines = 3;
display.setTextSize(1);
_initialized = true;
Serial.printf("Notes layout: %d chars/line, %d lines/page, lineH=%d (edit: %d cpl, %d lines)\n",
_charsPerLine, _linesPerPage, _lineHeight,
_editCharsPerLine, _editMaxLines);
}
// ---- Public Interface ----
void setSDReady(bool ready) { _sdReady = ready; }
bool isSDReady() const { return _sdReady; }
bool isDirty() const { return _dirty; }
void triggerSaveAndExit() { saveAndExit(); }
void setTimestamp(uint32_t rtcTime, int8_t utcOffset) {
_rtcTime = rtcTime;
_utcOffset = utcOffset;
}
void setTimeGetter(TimeGetterFn fn) { _getTimeFn = fn; }
void enter(DisplayDriver& display) {
initLayout(display);
scanFiles();
if (_mode != EDITING) {
_selectedFile = 0;
_mode = FILE_LIST;
}
}
Mode getMode() const { return _mode; }
bool isEditing() const { return _mode == EDITING; }
bool isReading() const { return _mode == READING; }
bool isInFileList() const { return _mode == FILE_LIST; }
bool isRenaming() const { return _mode == RENAMING; }
bool isConfirmingDelete() const { return _mode == CONFIRM_DELETE; }
bool isEmpty() const { return _bufLen == 0; }
// Touch: select file list row by virtual Y coordinate
// Returns: 0 = outside list, 1 = moved selection, 2 = tapped same row (open)
int selectRowAtVY(int vy) {
if (_mode != FILE_LIST) return 0;
const int startY = 14, footerH = 14;
const int listLineH = _prefs ? _prefs->smallLineH() : 9;
#if defined(LilyGo_T5S3_EPaper_Pro)
const int bodyTop = startY;
#else
const int bodyTop = startY + (_prefs ? _prefs->smallHighlightOff() : 5);
#endif
if (vy < bodyTop || vy >= 128 - footerH) return 0;
int totalItems = 1 + (int)_fileList.size();
if (totalItems == 0) return 0;
int maxVisible = (128 - startY - footerH) / listLineH;
if (maxVisible < 3) maxVisible = 3;
if (maxVisible > 15) maxVisible = 15;
int startIdx = max(0, min(_selectedFile - maxVisible / 2,
totalItems - maxVisible));
int tappedRow = startIdx + (vy - bodyTop) / listLineH;
if (tappedRow < 0 || tappedRow >= totalItems) return 0;
if (tappedRow == _selectedFile) return 2;
_selectedFile = tappedRow;
return 1;
}
// ---- Cursor Navigation (called from main.cpp) ----
void moveCursorLeft() {
if (_cursorPos > 0) {
_cursorPos--;
while (_cursorPos > 0 && (uint8_t)_buf[_cursorPos] >= 0x80 &&
(uint8_t)_buf[_cursorPos] < 0xC0) {
_cursorPos--;
}
}
}
void moveCursorRight() {
if (_cursorPos < _bufLen) {
_cursorPos++;
while (_cursorPos < _bufLen && (uint8_t)_buf[_cursorPos] >= 0x80 &&
(uint8_t)_buf[_cursorPos] < 0xC0) {
_cursorPos++;
}
}
}
void moveCursorUp() {
buildEditorLines();
int curLine = lineForPos(_cursorPos);
if (curLine > 0) {
int col = colForPos(_cursorPos, _editorLines[curLine].start);
_cursorPos = posForCol(col, curLine - 1);
}
}
void moveCursorDown() {
buildEditorLines();
int curLine = lineForPos(_cursorPos);
if (curLine < _numEditorLines - 1) {
int col = colForPos(_cursorPos, _editorLines[curLine].start);
_cursorPos = posForCol(col, curLine + 1);
}
}
// ---- File List Actions (called from main.cpp) ----
bool startRename() {
if (_selectedFile < 1 || _selectedFile > (int)_fileList.size()) return false;
_renameOriginal = _fileList[_selectedFile - 1];
// Strip .txt extension for editing
String base = _renameOriginal;
if (base.endsWith(".txt") || base.endsWith(".TXT")) {
base = base.substring(0, base.length() - 4);
}
int len = min((int)base.length(), NOTES_RENAME_MAX - 2);
memcpy(_renameBuf, base.c_str(), len);
_renameBuf[len] = '\0';
_renameLen = len;
_mode = RENAMING;
Serial.printf("Notes: Renaming %s\n", _renameOriginal.c_str());
return true;
}
bool startDeleteFromList() {
if (_selectedFile < 1 || _selectedFile > (int)_fileList.size()) return false;
_deleteTarget = _fileList[_selectedFile - 1];
_mode = CONFIRM_DELETE;
Serial.printf("Notes: Confirm delete %s\n", _deleteTarget.c_str());
return true;
}
void saveAndExit() {
if (_dirty && _currentFile.length() > 0) {
if (_bufLen > 0) {
drawBriefSplash("Saving...");
saveNote();
} else if (_dirty) {
Serial.printf("Notes: Skipping empty note %s\n", _currentFile.c_str());
}
}
_mode = FILE_LIST;
_dirty = false;
scanFiles();
}
void discardAndExit() {
_dirty = false;
_mode = FILE_LIST;
scanFiles();
}
void deleteCurrentNote() {
if (_currentFile.length() > 0) {
drawBriefSplash("Deleting...");
deleteNote(_currentFile);
}
_dirty = false;
_currentFile = "";
_bufLen = 0;
_buf[0] = '\0';
_cursorPos = 0;
_mode = FILE_LIST;
_selectedFile = 0;
scanFiles();
}
void exitNotes() {
if (_dirty && _bufLen > 0) {
saveNote();
}
_mode = FILE_LIST;
_dirty = false;
}
// ---- UIScreen overrides ----
int render(DisplayDriver& display) override {
if (!_sdReady) {
display.setCursor(0, 20);
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.print("SD card not found");
display.setCursor(0, 35);
display.print("Insert SD card");
return 5000;
}
switch (_mode) {
case FILE_LIST: renderFileList(display); break;
case READING: renderReadPage(display); break;
case EDITING: renderEditor(display); break;
case RENAMING: renderRenameDialog(display); break;
case CONFIRM_DELETE: renderDeleteConfirm(display); break;
}
return 5000;
}
bool handleInput(char c) override {
switch (_mode) {
case FILE_LIST: return handleFileListInput(c);
case READING: return handleReadInput(c);
case EDITING: return handleEditInput(c);
case RENAMING: return handleRenameInput(c);
case CONFIRM_DELETE: return handleDeleteConfirmInput(c);
}
return false;
}
};