mirror of
https://github.com/pelgraine/Meck.git
synced 2026-07-04 16:51:20 +02:00
Create, edit, save and delete txt notes from the N menu from any home view screen
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
#include "TCA8418Keyboard.h"
|
||||
#include <SD.h>
|
||||
#include "TextReaderScreen.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "ContactsScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "SettingsScreen.h"
|
||||
@@ -43,6 +44,9 @@
|
||||
// Text reader mode state
|
||||
static bool readerMode = false;
|
||||
|
||||
// Notes mode state
|
||||
static bool notesMode = false;
|
||||
|
||||
// Power management
|
||||
#if HAS_GPS
|
||||
GPSDutyCycle gpsDuty;
|
||||
@@ -400,7 +404,7 @@ void setup() {
|
||||
MESH_DEBUG_PRINTLN("setup() - SPIFFS.begin() done");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Early SD card init — needed BEFORE the_mesh.begin() so we can restore
|
||||
// Early SD card init  needed BEFORE the_mesh.begin() so we can restore
|
||||
// settings from a previous firmware flash. The display SPI bus is already
|
||||
// up (display.begin() ran earlier), so SD can share it now.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -508,6 +512,12 @@ void setup() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tell notes screen that SD is ready
|
||||
NotesScreen* notesScr = (NotesScreen*)ui_task.getNotesScreen();
|
||||
if (notesScr) {
|
||||
notesScr->setSDReady(true);
|
||||
}
|
||||
|
||||
// Do an initial settings backup to SD (captures any first-boot defaults)
|
||||
backupSettingsToSD();
|
||||
}
|
||||
@@ -530,7 +540,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
// GPS duty cycle — honour saved pref, default to enabled on first boot
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_wanted = the_mesh.getNodePrefs()->gps_enabled;
|
||||
@@ -545,7 +555,7 @@ void setup() {
|
||||
}
|
||||
#endif
|
||||
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
// CPU frequency scaling — drop to 80 MHz for idle mesh listening
|
||||
cpuPower.begin();
|
||||
|
||||
// T-Deck Pro: BLE starts disabled for standalone-first operation
|
||||
@@ -561,7 +571,7 @@ void setup() {
|
||||
void loop() {
|
||||
the_mesh.loop();
|
||||
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
// GPS duty cycle — check for fix and manage power state
|
||||
#if HAS_GPS
|
||||
{
|
||||
bool gps_hw_on = gpsDuty.loop();
|
||||
@@ -581,22 +591,31 @@ void loop() {
|
||||
#ifdef DISPLAY_CLASS
|
||||
// Skip UITask rendering when in compose mode to prevent flickering
|
||||
#if defined(LilyGo_TDeck_Pro)
|
||||
if (!composeMode) {
|
||||
// Also suppress during notes editing (same debounce pattern as compose)
|
||||
bool notesEditing = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isEditing();
|
||||
if (!composeMode && !notesEditing) {
|
||||
ui_task.loop();
|
||||
} else {
|
||||
// Handle debounced compose/emoji picker screen refresh
|
||||
// Handle debounced screen refresh (compose, emoji picker, or notes editor)
|
||||
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
|
||||
if (emojiPickerMode) {
|
||||
drawEmojiPicker();
|
||||
} else {
|
||||
drawComposeScreen();
|
||||
if (composeMode) {
|
||||
if (emojiPickerMode) {
|
||||
drawEmojiPicker();
|
||||
} else {
|
||||
drawComposeScreen();
|
||||
}
|
||||
} else if (notesEditing) {
|
||||
// Notes editor renders through UITask - force a refresh cycle
|
||||
ui_task.forceRefresh();
|
||||
ui_task.loop();
|
||||
}
|
||||
lastComposeRefresh = millis();
|
||||
composeNeedsRefresh = false;
|
||||
}
|
||||
}
|
||||
// Track reader mode state for key routing
|
||||
// Track reader/notes mode state for key routing
|
||||
readerMode = ui_task.isOnTextReader();
|
||||
notesMode = ui_task.isOnNotesScreen();
|
||||
#else
|
||||
ui_task.loop();
|
||||
#endif
|
||||
@@ -810,6 +829,83 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// *** NOTES MODE ***
|
||||
if (notesMode) {
|
||||
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
|
||||
|
||||
if (notes->isEditing()) {
|
||||
// In editor: Shift+Backspace = save and exit
|
||||
if (key == '\b') {
|
||||
if (keyboard.wasShiftConsumed()) {
|
||||
Serial.println("Notes: Shift+Backspace, saving...");
|
||||
notes->saveAndExit();
|
||||
ui_task.forceRefresh();
|
||||
return;
|
||||
}
|
||||
// Regular backspace - pass to editor with debounce
|
||||
ui_task.injectKey(key);
|
||||
composeNeedsRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Q when buffer is empty or unchanged = exit (nothing to lose)
|
||||
if (key == 'q' && (notes->isEmpty() || !notes->isDirty())) {
|
||||
Serial.println("Notes: Q exit (nothing to save)");
|
||||
notes->discardAndExit();
|
||||
ui_task.forceRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter = newline (pass through with debounce)
|
||||
if (key == '\r') {
|
||||
ui_task.injectKey(key);
|
||||
composeNeedsRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// All other printable chars
|
||||
if (key >= 32 && key < 127) {
|
||||
ui_task.injectKey(key);
|
||||
composeNeedsRefresh = true;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In file list or reading mode
|
||||
if (key == 'q') {
|
||||
if (notes->isReading()) {
|
||||
ui_task.injectKey('q'); // Let screen handle back-to-list
|
||||
} else {
|
||||
// On file list - exit notes, go home
|
||||
notes->exitNotes();
|
||||
Serial.println("Exiting notes");
|
||||
ui_task.gotoHomeScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Backspace in reading mode = delete note
|
||||
if (key == '\b' && notes->isReading()) {
|
||||
if (keyboard.wasShiftConsumed()) {
|
||||
Serial.println("Notes: Deleting current note");
|
||||
notes->deleteCurrentNote();
|
||||
ui_task.forceRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// All other keys pass through to the notes screen
|
||||
ui_task.injectKey(key);
|
||||
// If we just entered editing mode (e.g. Enter on file list or reading),
|
||||
// prime the initial draw since notesEditing suppresses ui_task.loop()
|
||||
if (notes->isEditing()) {
|
||||
composeNeedsRefresh = true;
|
||||
lastComposeRefresh = 0; // Allow immediate refresh on next loop
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// *** SETTINGS MODE ***
|
||||
if (ui_task.isOnSettingsScreen()) {
|
||||
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
|
||||
@@ -826,7 +922,7 @@ void handleKeyboardInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
// All other keys → settings screen via injectKey
|
||||
// All other keys → settings screen via injectKey
|
||||
ui_task.injectKey(key);
|
||||
return;
|
||||
}
|
||||
@@ -851,6 +947,12 @@ void handleKeyboardInput() {
|
||||
ui_task.gotoTextReader();
|
||||
break;
|
||||
|
||||
case 'n':
|
||||
// Open notes
|
||||
Serial.println("Opening notes");
|
||||
ui_task.gotoNotesScreen();
|
||||
break;
|
||||
|
||||
case 's':
|
||||
// Open settings (from home), or navigate down on channel/contacts
|
||||
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/UIScreen.h>
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <SD.h>
|
||||
#include <vector>
|
||||
#include "Utf8CP437.h"
|
||||
|
||||
// Forward declarations
|
||||
class UITask;
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
#define NOTES_FOLDER "/notes"
|
||||
#define NOTES_MAX_FILES 30
|
||||
#define NOTES_BUF_SIZE 4096 // Max note size in bytes (~4000 chars)
|
||||
#define NOTES_FILENAME_MAX 32
|
||||
|
||||
// ============================================================================
|
||||
// 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 - Append-only text editor (type at end, backspace to delete)
|
||||
//
|
||||
// Key bindings:
|
||||
// FILE_LIST: W/S = scroll, Enter = open note (read), Q = exit notes
|
||||
// READING: W/S = page nav, Enter = switch to edit mode, Q = back to list
|
||||
// EDITING: Type = append, Backspace = delete last char, Enter = newline
|
||||
// Shift+Backspace = save & exit to file list
|
||||
//
|
||||
// New notes get auto-generated filenames: note_001.txt, note_002.txt, etc.
|
||||
// The "+ New Note" option is always at the top of the file list.
|
||||
// ============================================================================
|
||||
|
||||
class NotesScreen : public UIScreen {
|
||||
public:
|
||||
enum Mode { FILE_LIST, READING, EDITING };
|
||||
|
||||
private:
|
||||
UITask* _task;
|
||||
Mode _mode;
|
||||
bool _sdReady;
|
||||
bool _initialized;
|
||||
DisplayDriver* _display;
|
||||
|
||||
// Display layout (calculated once from display metrics)
|
||||
int _charsPerLine;
|
||||
int _linesPerPage;
|
||||
int _lineHeight;
|
||||
int _footerHeight;
|
||||
|
||||
// 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[NOTES_BUF_SIZE]; // Note content buffer
|
||||
int _bufLen; // Current content length
|
||||
bool _dirty; // Has unsaved changes
|
||||
|
||||
// Reading state (paginated view)
|
||||
int _currentPage;
|
||||
int _totalPages;
|
||||
std::vector<int> _pageOffsets; // Byte offsets for each page start
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
String getFullPath(const String& filename) {
|
||||
return String(NOTES_FOLDER) + "/" + filename;
|
||||
}
|
||||
|
||||
// Generate a sequential filename: note_001.txt, note_002.txt, etc.
|
||||
String generateFilename() {
|
||||
int maxNum = 0;
|
||||
|
||||
// Scan existing files to find the highest note number
|
||||
for (int i = 0; i < (int)_fileList.size(); i++) {
|
||||
const String& name = _fileList[i];
|
||||
// Match pattern: note_NNN.txt
|
||||
if (name.startsWith("note_") && name.endsWith(".txt")) {
|
||||
String numPart = name.substring(5, name.length() - 4);
|
||||
int num = numPart.toInt();
|
||||
if (num > maxNum) maxNum = num;
|
||||
}
|
||||
}
|
||||
|
||||
int nextNum = maxNum + 1;
|
||||
char name[NOTES_FILENAME_MAX];
|
||||
snprintf(name, sizeof(name), "note_%03d.txt", nextNum);
|
||||
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 (newest timestamp names sort last)
|
||||
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();
|
||||
|
||||
// Deselect SD to free SPI bus
|
||||
digitalWrite(SDCARD_CS, HIGH);
|
||||
|
||||
_currentFile = filename;
|
||||
_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);
|
||||
|
||||
// Remove existing file before writing (SD lib quirk)
|
||||
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();
|
||||
|
||||
// Deselect SD to free SPI bus
|
||||
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;
|
||||
}
|
||||
|
||||
// ---- Pagination for Read Mode ----
|
||||
// Reuses the same word-wrap logic as TextReaderScreen but operates
|
||||
// on the in-memory buffer instead of streaming from SD.
|
||||
|
||||
void buildPageIndex() {
|
||||
_pageOffsets.clear();
|
||||
_pageOffsets.push_back(0);
|
||||
|
||||
int pos = 0;
|
||||
int lineCount = 0;
|
||||
|
||||
while (pos < _bufLen) {
|
||||
// Find end of this line using word wrap
|
||||
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 lineFound;
|
||||
}
|
||||
if (c == '\r') {
|
||||
lineEnd = i;
|
||||
nextStart = i + 1;
|
||||
if (nextStart < _bufLen && _buf[nextStart] == '\n') nextStart++;
|
||||
goto lineFound;
|
||||
}
|
||||
|
||||
if (c >= 32) {
|
||||
// Skip UTF-8 continuation bytes
|
||||
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 lineFound;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reached end of buffer
|
||||
break;
|
||||
|
||||
lineFound:
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendering ----
|
||||
|
||||
// Quick feedback splash (shown briefly during SD operations)
|
||||
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");
|
||||
|
||||
snprintf(tmp, sizeof(tmp), "[%d]", (int)_fileList.size());
|
||||
display.setCursor(display.width() - display.getTextWidth(tmp) - 2, 0);
|
||||
display.print(tmp);
|
||||
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
|
||||
// File list with "+ New Note" at index 0
|
||||
display.setTextSize(0); // Tiny font
|
||||
int listLineH = 8;
|
||||
int startY = 14;
|
||||
int totalItems = 1 + (int)_fileList.size(); // +1 for "New Note"
|
||||
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);
|
||||
display.fillRect(0, y + 5, display.width(), listLineH);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
display.setCursor(0, y);
|
||||
|
||||
if (i == 0) {
|
||||
// "+ New Note" option
|
||||
display.setColor(selected ? DisplayDriver::DARK : DisplayDriver::GREEN);
|
||||
display.print(selected ? "> + New Note" : " + New Note");
|
||||
} else {
|
||||
// Existing file
|
||||
String line = selected ? "> " : " ";
|
||||
String name = _fileList[i - 1];
|
||||
|
||||
// Truncate if needed
|
||||
int maxLen = _charsPerLine - 4;
|
||||
if ((int)name.length() > maxLen) {
|
||||
name = name.substring(0, maxLen - 3) + "...";
|
||||
}
|
||||
line += name;
|
||||
display.print(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);
|
||||
display.print("Q:Back W/S:Nav");
|
||||
|
||||
const char* right = "Ent:Open";
|
||||
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)");
|
||||
|
||||
// Still show footer
|
||||
int footerY = display.height() - 12;
|
||||
display.drawRect(0, footerY - 2, display.width(), 1);
|
||||
display.setColor(DisplayDriver::YELLOW);
|
||||
display.setCursor(0, footerY);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
return;
|
||||
}
|
||||
|
||||
// Render current page using tiny font
|
||||
display.setTextSize(0);
|
||||
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) {
|
||||
// Find line end with word wrap
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// End of page data
|
||||
lineEnd = min(pageEnd, _bufLen);
|
||||
nextStart = lineEnd;
|
||||
|
||||
renderLine:
|
||||
display.setCursor(0, y);
|
||||
|
||||
// Print characters with UTF-8 -> CP437 mapping
|
||||
char charStr[2] = {0, 0};
|
||||
int j = pos;
|
||||
while (j < lineEnd && j < _bufLen) {
|
||||
uint8_t b = (uint8_t)_buf[j];
|
||||
if (b < 32) { j++; continue; }
|
||||
if (b < 0x80) {
|
||||
charStr[0] = (char)b;
|
||||
display.print(charStr);
|
||||
j++;
|
||||
} else if (b >= 0xC0) {
|
||||
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);
|
||||
display.print("Q:Bck Ent:Edit");
|
||||
|
||||
const char* right = "Sh+Del:Del";
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
|
||||
void renderEditor(DisplayDriver& display) {
|
||||
// Header
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::GREEN);
|
||||
display.setCursor(0, 0);
|
||||
|
||||
// Show filename (truncated) + editing indicator
|
||||
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);
|
||||
|
||||
// Render the TAIL of the buffer (most recent text) with word wrap.
|
||||
// We want the cursor (end of text) to be visible, so we render
|
||||
// backwards from the end to fill available screen lines.
|
||||
|
||||
int textAreaTop = 14;
|
||||
int textAreaBottom = display.height() - 16; // Leave room for footer
|
||||
int maxLines = (textAreaBottom - textAreaTop) / 12; // ~12 virtual units per line at size 1
|
||||
if (maxLines < 3) maxLines = 3;
|
||||
|
||||
// Simple approach: find how many characters fit on screen by working
|
||||
// backwards from buffer end to find the screen start position.
|
||||
// Use size 1 font for the editor (bigger than tiny, easier to read while typing).
|
||||
display.setTextSize(1);
|
||||
|
||||
// Measure char width for word wrap
|
||||
uint16_t charW = display.getTextWidth("M");
|
||||
int editCharsPerLine = charW > 0 ? display.width() / charW : 20;
|
||||
if (editCharsPerLine < 10) editCharsPerLine = 10;
|
||||
if (editCharsPerLine > 40) editCharsPerLine = 40;
|
||||
|
||||
// Build visible lines from the buffer (last N lines)
|
||||
// We'll collect lines in reverse and then display them.
|
||||
struct Line { int start; int end; };
|
||||
Line lines[32]; // More than enough for any screen
|
||||
int lineCount = 0;
|
||||
|
||||
// Forward pass to find all line breaks
|
||||
int tmpLines = 0;
|
||||
int pos = 0;
|
||||
int allLineStarts[512];
|
||||
int allLineEnds[512];
|
||||
|
||||
while (pos < _bufLen && tmpLines < 512) {
|
||||
allLineStarts[tmpLines] = pos;
|
||||
|
||||
// Find line end
|
||||
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 editorLineFound; }
|
||||
if (c == '\r') {
|
||||
lineEnd = i; nextStart = i + 1;
|
||||
if (nextStart < _bufLen && _buf[nextStart] == '\n') nextStart++;
|
||||
goto editorLineFound;
|
||||
}
|
||||
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 editorLineFound;
|
||||
}
|
||||
}
|
||||
}
|
||||
lineEnd = _bufLen;
|
||||
nextStart = _bufLen;
|
||||
|
||||
editorLineFound:
|
||||
allLineEnds[tmpLines] = lineEnd;
|
||||
tmpLines++;
|
||||
pos = nextStart;
|
||||
if (pos >= _bufLen) break;
|
||||
}
|
||||
|
||||
// If buffer is empty, we have 0 lines - that's fine, cursor still shows
|
||||
|
||||
// Take the last maxLines lines
|
||||
int firstVisible = max(0, tmpLines - maxLines);
|
||||
lineCount = tmpLines - firstVisible;
|
||||
|
||||
// Render visible lines
|
||||
int y = textAreaTop;
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
for (int li = firstVisible; li < tmpLines; li++) {
|
||||
display.setCursor(0, y);
|
||||
char charStr[2] = {0, 0};
|
||||
for (int j = allLineStarts[li]; j < allLineEnds[li] && j < _bufLen; j++) {
|
||||
uint8_t b = (uint8_t)_buf[j];
|
||||
if (b < 32) continue;
|
||||
charStr[0] = (char)b;
|
||||
display.print(charStr);
|
||||
}
|
||||
y += 12;
|
||||
}
|
||||
|
||||
// Show cursor at end of last line (or at start if empty)
|
||||
if (tmpLines == 0 || lineCount == 0) {
|
||||
display.setCursor(0, textAreaTop);
|
||||
}
|
||||
display.print("_");
|
||||
|
||||
// Footer / status bar
|
||||
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[40];
|
||||
snprintf(status, sizeof(status), "%d/%d", _bufLen, NOTES_BUF_SIZE - 1);
|
||||
display.print(status);
|
||||
|
||||
// Show Q:Back when there's nothing to lose, Sh+Del:Save when there is
|
||||
const char* right;
|
||||
if (_bufLen == 0 || !_dirty) {
|
||||
right = "Q:Back";
|
||||
} else {
|
||||
right = "Sh+Del:Save";
|
||||
}
|
||||
display.setCursor(display.width() - display.getTextWidth(right) - 2, footerY);
|
||||
display.print(right);
|
||||
}
|
||||
|
||||
// ---- Input Handling ----
|
||||
|
||||
bool handleFileListInput(char c) {
|
||||
int totalItems = 1 + (int)_fileList.size();
|
||||
|
||||
// W - scroll up
|
||||
if (c == 'w' || c == 'W' || c == 0xF2) {
|
||||
if (_selectedFile > 0) { _selectedFile--; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// S - scroll down
|
||||
if (c == 's' || 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) {
|
||||
// Create new note
|
||||
createNewNote();
|
||||
return true;
|
||||
} else {
|
||||
// Open existing note
|
||||
int fileIdx = _selectedFile - 1;
|
||||
if (fileIdx >= 0 && fileIdx < (int)_fileList.size()) {
|
||||
if (loadNote(_fileList[fileIdx])) {
|
||||
buildPageIndex();
|
||||
_currentPage = 0;
|
||||
_mode = READING;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool handleReadInput(char c) {
|
||||
// W/A - previous page
|
||||
if (c == 'w' || c == 'W' || c == 'a' || c == 'A' || c == 0xF2) {
|
||||
if (_currentPage > 0) { _currentPage--; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// S/D/Space - next page
|
||||
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 (jump to end of buffer)
|
||||
if (c == '\r' || c == 13) {
|
||||
_mode = EDITING;
|
||||
Serial.printf("Notes: Editing %s (%d bytes)\n", _currentFile.c_str(), _bufLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Q - back to file list
|
||||
if (c == 'q' || c == 'Q') {
|
||||
_mode = FILE_LIST;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Returns: true if screen needs refresh, false otherwise.
|
||||
// Special return via _mode change: if Shift+Backspace detected externally
|
||||
// (in main.cpp notesMode handler), caller saves and exits.
|
||||
bool handleEditInput(char c) {
|
||||
// Backspace
|
||||
if (c == '\b') {
|
||||
if (_bufLen > 0) {
|
||||
_bufLen--;
|
||||
_buf[_bufLen] = '\0';
|
||||
_dirty = true;
|
||||
return true; // Will be debounced by caller
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - insert newline
|
||||
if (c == '\r' || c == 13) {
|
||||
if (_bufLen < NOTES_BUF_SIZE - 2) {
|
||||
_buf[_bufLen++] = '\n';
|
||||
_buf[_bufLen] = '\0';
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Regular printable character
|
||||
if (c >= 32 && c < 127 && _bufLen < NOTES_BUF_SIZE - 1) {
|
||||
_buf[_bufLen++] = c;
|
||||
_buf[_bufLen] = '\0';
|
||||
_dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---- Note Creation ----
|
||||
|
||||
void createNewNote() {
|
||||
_currentFile = generateFilename();
|
||||
_buf[0] = '\0';
|
||||
_bufLen = 0;
|
||||
_dirty = true;
|
||||
_mode = EDITING;
|
||||
Serial.printf("Notes: Created new note %s\n", _currentFile.c_str());
|
||||
}
|
||||
|
||||
public:
|
||||
NotesScreen(UITask* task)
|
||||
: _task(task), _mode(FILE_LIST), _sdReady(false), _initialized(false),
|
||||
_display(nullptr),
|
||||
_charsPerLine(38), _linesPerPage(22), _lineHeight(5), _footerHeight(14),
|
||||
_selectedFile(0), _bufLen(0), _dirty(false),
|
||||
_currentPage(0), _totalPages(0) {
|
||||
_buf[0] = '\0';
|
||||
}
|
||||
|
||||
// ---- Layout Init (call once after display available) ----
|
||||
|
||||
void initLayout(DisplayDriver& display) {
|
||||
if (_initialized) return;
|
||||
_display = &display;
|
||||
|
||||
// Measure tiny font metrics (matches TextReaderScreen)
|
||||
display.setTextSize(0);
|
||||
uint16_t tenCharsW = display.getTextWidth("MMMMMMMMMM");
|
||||
if (tenCharsW > 0) {
|
||||
_charsPerLine = (display.width() * 10) / tenCharsW;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
_footerHeight = 14;
|
||||
int textAreaHeight = display.height() - _footerHeight;
|
||||
_linesPerPage = textAreaHeight / _lineHeight;
|
||||
if (_linesPerPage < 5) _linesPerPage = 5;
|
||||
|
||||
display.setTextSize(1); // Restore
|
||||
_initialized = true;
|
||||
|
||||
Serial.printf("Notes layout: %d chars/line, %d lines/page, lineH=%d\n",
|
||||
_charsPerLine, _linesPerPage, _lineHeight);
|
||||
}
|
||||
|
||||
// ---- Public Interface ----
|
||||
|
||||
void setSDReady(bool ready) { _sdReady = ready; }
|
||||
bool isSDReady() const { return _sdReady; }
|
||||
|
||||
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 isDirty() const { return _dirty; }
|
||||
bool isEmpty() const { return _bufLen == 0; }
|
||||
|
||||
// Save current note and return to file list
|
||||
void saveAndExit() {
|
||||
if (_dirty && _currentFile.length() > 0) {
|
||||
// Don't save empty new notes (user created then immediately exited)
|
||||
if (_bufLen > 0) {
|
||||
drawBriefSplash("Saving...");
|
||||
saveNote();
|
||||
} else if (_dirty) {
|
||||
// New note with no content - skip saving
|
||||
Serial.printf("Notes: Skipping empty note %s\n", _currentFile.c_str());
|
||||
}
|
||||
}
|
||||
_mode = FILE_LIST;
|
||||
_dirty = false;
|
||||
scanFiles(); // Refresh list to show new/updated file
|
||||
}
|
||||
|
||||
// Discard changes and return to file list
|
||||
void discardAndExit() {
|
||||
_dirty = false;
|
||||
_mode = FILE_LIST;
|
||||
scanFiles();
|
||||
}
|
||||
|
||||
// Delete the currently open note and return to file list
|
||||
void deleteCurrentNote() {
|
||||
if (_currentFile.length() > 0) {
|
||||
drawBriefSplash("Deleting...");
|
||||
deleteNote(_currentFile);
|
||||
}
|
||||
_dirty = false;
|
||||
_currentFile = "";
|
||||
_bufLen = 0;
|
||||
_buf[0] = '\0';
|
||||
_mode = FILE_LIST;
|
||||
_selectedFile = 0;
|
||||
scanFiles();
|
||||
}
|
||||
|
||||
// Exit notes screen entirely
|
||||
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;
|
||||
}
|
||||
|
||||
if (_mode == FILE_LIST) {
|
||||
renderFileList(display);
|
||||
} else if (_mode == READING) {
|
||||
renderReadPage(display);
|
||||
} else if (_mode == EDITING) {
|
||||
renderEditor(display);
|
||||
}
|
||||
|
||||
return 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (_mode == FILE_LIST) {
|
||||
return handleFileListInput(c);
|
||||
} else if (_mode == READING) {
|
||||
return handleReadInput(c);
|
||||
} else if (_mode == EDITING) {
|
||||
return handleEditInput(c);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "UITask.h"
|
||||
#include <helpers/TxtDataHelpers.h>
|
||||
#include "../MyMesh.h"
|
||||
#include "NotesScreen.h"
|
||||
#include "target.h"
|
||||
#include "GPSDutyCycle.h"
|
||||
#ifdef WIFI_SSID
|
||||
@@ -373,7 +374,7 @@ public:
|
||||
display.drawTextRightAlign(display.width()-1, y, buf);
|
||||
y = y + 12;
|
||||
|
||||
// NMEA sentence counter — confirms baud rate and data flow
|
||||
// NMEA sentence counter  confirms baud rate and data flow
|
||||
display.drawTextLeftAlign(0, y, "sentences");
|
||||
if (gpsDuty.isHardwareOn()) {
|
||||
uint16_t sps = gpsStream.getSentencesPerSec();
|
||||
@@ -747,6 +748,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
channel_screen = new ChannelScreen(this, &rtc_clock);
|
||||
contacts_screen = new ContactsScreen(this, &rtc_clock);
|
||||
text_reader = new TextReaderScreen(this);
|
||||
notes_screen = new NotesScreen(this);
|
||||
settings_screen = new SettingsScreen(this, &rtc_clock, node_prefs);
|
||||
setCurrScreen(splash);
|
||||
}
|
||||
@@ -1080,13 +1082,13 @@ void UITask::toggleGPS() {
|
||||
|
||||
if (_sensors != NULL) {
|
||||
if (_node_prefs->gps_enabled) {
|
||||
// Disable GPS — cut hardware power
|
||||
// Disable GPS  cut hardware power
|
||||
_sensors->setSettingValue("gps", "0");
|
||||
_node_prefs->gps_enabled = 0;
|
||||
gpsDuty.disable();
|
||||
notify(UIEventType::ack);
|
||||
} else {
|
||||
// Enable GPS — start duty cycle
|
||||
// Enable GPS  start duty cycle
|
||||
_sensors->setSettingValue("gps", "1");
|
||||
_node_prefs->gps_enabled = 1;
|
||||
gpsDuty.enable();
|
||||
@@ -1184,6 +1186,19 @@ void UITask::gotoTextReader() {
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoNotesScreen() {
|
||||
NotesScreen* notes = (NotesScreen*)notes_screen;
|
||||
if (_display != NULL) {
|
||||
notes->enter(*_display);
|
||||
}
|
||||
setCurrScreen(notes_screen);
|
||||
if (_display != NULL && !_display->isOn()) {
|
||||
_display->turnOn();
|
||||
}
|
||||
_auto_off = millis() + AUTO_OFF_MILLIS;
|
||||
_next_refresh = 100;
|
||||
}
|
||||
|
||||
void UITask::gotoSettingsScreen() {
|
||||
((SettingsScreen *) settings_screen)->enter();
|
||||
setCurrScreen(settings_screen);
|
||||
|
||||
@@ -54,6 +54,7 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* channel_screen; // Channel message history screen
|
||||
UIScreen* contacts_screen; // Contacts list screen
|
||||
UIScreen* text_reader; // *** NEW: Text reader screen ***
|
||||
UIScreen* notes_screen; // Notes editor screen
|
||||
UIScreen* settings_screen; // Settings/onboarding screen
|
||||
UIScreen* curr;
|
||||
|
||||
@@ -80,6 +81,7 @@ public:
|
||||
void gotoChannelScreen(); // Navigate to channel message screen
|
||||
void gotoContactsScreen(); // Navigate to contacts list
|
||||
void gotoTextReader(); // *** NEW: Navigate to text reader ***
|
||||
void gotoNotesScreen(); // Navigate to notes editor
|
||||
void gotoSettingsScreen(); // Navigate to settings
|
||||
void gotoOnboarding(); // Navigate to settings in onboarding mode
|
||||
void showAlert(const char* text, int duration_millis) override;
|
||||
@@ -90,6 +92,7 @@ public:
|
||||
bool isOnChannelScreen() const { return curr == channel_screen; }
|
||||
bool isOnContactsScreen() const { return curr == contacts_screen; }
|
||||
bool isOnTextReader() const { return curr == text_reader; } // *** NEW ***
|
||||
bool isOnNotesScreen() const { return curr == notes_screen; }
|
||||
bool isOnSettingsScreen() const { return curr == settings_screen; }
|
||||
uint8_t getChannelScreenViewIdx() const;
|
||||
|
||||
@@ -110,6 +113,7 @@ public:
|
||||
UIScreen* getCurrentScreen() const { return curr; }
|
||||
UIScreen* getMsgPreviewScreen() const { return msg_preview; }
|
||||
UIScreen* getTextReaderScreen() const { return text_reader; } // *** NEW ***
|
||||
UIScreen* getNotesScreen() const { return notes_screen; }
|
||||
UIScreen* getContactsScreen() const { return contacts_screen; }
|
||||
UIScreen* getChannelScreen() const { return channel_screen; }
|
||||
UIScreen* getSettingsScreen() const { return settings_screen; }
|
||||
|
||||
@@ -29,6 +29,7 @@ private:
|
||||
TwoWire* _wire;
|
||||
bool _initialized;
|
||||
bool _shiftActive; // Sticky shift (one-shot)
|
||||
bool _shiftConsumed; // Was shift active for the last returned key
|
||||
bool _altActive; // Sticky alt (one-shot)
|
||||
bool _symActive; // Sticky sym (one-shot)
|
||||
unsigned long _lastShiftTime; // For Shift+key combos
|
||||
@@ -148,7 +149,7 @@ private:
|
||||
public:
|
||||
TCA8418Keyboard(uint8_t addr = 0x34, TwoWire* wire = &Wire)
|
||||
: _addr(addr), _wire(wire), _initialized(false),
|
||||
_shiftActive(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
|
||||
_shiftActive(false), _shiftConsumed(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
|
||||
|
||||
bool begin() {
|
||||
// Check if device responds
|
||||
@@ -277,6 +278,9 @@ public:
|
||||
c = c - 'a' + 'A';
|
||||
}
|
||||
_shiftActive = false; // Reset sticky shift
|
||||
_shiftConsumed = true; // Record that shift was active for this key
|
||||
} else {
|
||||
_shiftConsumed = false;
|
||||
}
|
||||
|
||||
if (c != 0) {
|
||||
@@ -294,4 +298,10 @@ public:
|
||||
bool wasShiftRecentlyPressed(unsigned long withinMs = 500) const {
|
||||
return (millis() - _lastShiftTime) < withinMs;
|
||||
}
|
||||
|
||||
// Check if shift was active when the most recent key was produced
|
||||
// (immune to e-ink refresh timing unlike wasShiftRecentlyPressed)
|
||||
bool wasShiftConsumed() const {
|
||||
return _shiftConsumed;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user