#pragma once #include #include #include #include #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 _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 _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; } };