mirror of
https://github.com/pelgraine/Meck.git
synced 2026-05-17 14:55:51 +02:00
1414 lines
42 KiB
C++
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;
|
|
}
|
|
}; |