6 Commits

Author SHA1 Message Date
pelgraine
2dd5c4f59f Slight nav bar ui change in notes mode and improved rename function responsiveness 2026-02-12 21:04:05 +11:00
pelgraine
ee2a27258b improved cursor tracking and rename file handling implented with r key in Notes file list view 2026-02-12 20:45:41 +11:00
pelgraine
5b868d51ca improved responsiveness and cursor tracking in notes function. updated firmware version in mymesh. 2026-02-12 20:32:21 +11:00
pelgraine
220006c229 "Increased buffer size. Shift+WASD → cursor nav in editing mode.
Shift+Backspace → save (editing), delete (reading/file list).
Shift+Enter → rename from file list.
RTC time passed via setTimestamp() when opening notes."
2026-02-12 20:18:57 +11:00
pelgraine
a60f4146d5 Create, edit, save and delete txt notes from the N menu from any home view screen 2026-02-12 19:41:59 +11:00
pelgraine
017b170e81 Moved epubprocessor files to better tree location and fixed txt file accented character rendering 2026-02-11 20:07:38 +11:00
10 changed files with 1797 additions and 42 deletions

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "11 Feb 2026"
#define FIRMWARE_BUILD_DATE "12 Feb 2026"
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.8.4"
#define FIRMWARE_VERSION "Meck v0.8.5"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -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"
@@ -25,7 +26,7 @@
static uint8_t composeChannelIdx = 0;
static unsigned long lastComposeRefresh = 0;
static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
#define COMPOSE_REFRESH_INTERVAL 100 // ms before starting e-ink refresh after keypress (refresh itself takes ~644ms)
// DM compose mode (direct message to a specific contact)
static bool composeDM = false;
@@ -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,33 @@ 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();
bool notesRenaming = notesMode && ((NotesScreen*)ui_task.getNotesScreen())->isRenaming();
bool notesSuppressLoop = notesEditing || notesRenaming;
if (!composeMode && !notesSuppressLoop) {
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 (notesSuppressLoop) {
// Notes editor/rename 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 +831,141 @@ void handleKeyboardInput() {
return;
}
// *** NOTES MODE ***
if (notesMode) {
NotesScreen* notes = (NotesScreen*)ui_task.getNotesScreen();
// ---- EDITING MODE ----
if (notes->isEditing()) {
// 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 - delete before cursor
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
// Cursor navigation via Shift+WASD (produces uppercase)
if (key == 'W') { notes->moveCursorUp(); composeNeedsRefresh = true; return; }
if (key == 'A') { notes->moveCursorLeft(); composeNeedsRefresh = true; return; }
if (key == 'S') { notes->moveCursorDown(); composeNeedsRefresh = true; return; }
if (key == 'D') { notes->moveCursorRight(); 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 (lowercase only - uppercase consumed by cursor nav)
if (key >= 32 && key < 127) {
ui_task.injectKey(key);
composeNeedsRefresh = true;
return;
}
return;
}
// ---- RENAMING MODE ----
if (notes->isRenaming()) {
// All input goes to rename handler (debounced like editing)
ui_task.injectKey(key);
composeNeedsRefresh = true;
if (!notes->isRenaming()) {
// Exited rename mode (confirmed or cancelled)
ui_task.forceRefresh();
}
return;
}
// ---- DELETE CONFIRMATION MODE ----
if (notes->isConfirmingDelete()) {
ui_task.injectKey(key);
if (!notes->isConfirmingDelete()) {
// Exited confirm mode
ui_task.forceRefresh();
}
return;
}
// ---- FILE LIST MODE ----
if (notes->isInFileList()) {
if (key == 'q') {
notes->exitNotes();
Serial.println("Exiting notes");
ui_task.gotoHomeScreen();
return;
}
// Shift+Backspace on a file = delete with confirmation
if (key == '\b' && keyboard.wasShiftConsumed()) {
if (notes->startDeleteFromList()) {
ui_task.forceRefresh();
}
return;
}
// R on a file = rename
if (key == 'r') {
if (notes->startRename()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis() - COMPOSE_REFRESH_INTERVAL; // Trigger on next loop iteration
}
return;
}
// Normal keys pass through
ui_task.injectKey(key);
// Check if we just entered editing mode (new note via Enter)
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
// ---- READING MODE ----
if (notes->isReading()) {
if (key == 'q') {
ui_task.injectKey('q');
return;
}
// Shift+Backspace = delete note
if (key == '\b' && keyboard.wasShiftConsumed()) {
Serial.println("Notes: Deleting current note");
notes->deleteCurrentNote();
ui_task.forceRefresh();
return;
}
// All other keys (Enter for edit, W/S for page nav)
ui_task.injectKey(key);
if (notes->isEditing()) {
composeNeedsRefresh = true;
lastComposeRefresh = millis(); // Draw after debounce interval, not immediately
}
return;
}
return;
}
// *** SETTINGS MODE ***
if (ui_task.isOnSettingsScreen()) {
SettingsScreen* settings = (SettingsScreen*)ui_task.getSettingsScreen();
@@ -826,7 +982,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 +1007,20 @@ void handleKeyboardInput() {
ui_task.gotoTextReader();
break;
case 'n':
// Open notes
Serial.println("Opening notes");
{
NotesScreen* notesScr2 = (NotesScreen*)ui_task.getNotesScreen();
if (notesScr2) {
uint32_t ts = rtc_clock.getCurrentTime();
int8_t utcOff = the_mesh.getNodePrefs()->utc_offset_hours;
notesScr2->setTimestamp(ts, utcOff);
}
}
ui_task.gotoNotesScreen();
break;
case 's':
// Open settings (from home), or navigate down on channel/contacts
if (ui_task.isOnChannelScreen() || ui_task.isOnContactsScreen()) {

View File

@@ -2,8 +2,8 @@
// =============================================================================
// EpubProcessor.h - Convert EPUB files to plain text for TextReaderScreen
//
// Pipeline: EPUB (ZIP) container.xml OPF spine extract chapters
// strip XHTML tags concatenated plain text cached .txt on SD
// Pipeline: EPUB (ZIP) → container.xml → OPF spine → extract chapters →
// strip XHTML tags → concatenated plain text → cached .txt on SD
//
// The resulting .txt file is placed in /books/ and picked up automatically
// by TextReaderScreen's existing pagination, indexing, and bookmarking.
@@ -14,6 +14,7 @@
#include <SD.h>
#include <FS.h>
#include "EpubZipReader.h"
#include "Utf8CP437.h"
// Maximum chapters in spine (most novels have 20-80)
#define EPUB_MAX_CHAPTERS 200
@@ -426,7 +427,7 @@ private:
//
// Handles:
// - Tag removal (everything between < and >)
// - <p>, <br>, <div>, <h1>-<h6> newlines
// - <p>, <br>, <div>, <h1>-<h6> → newlines
// - HTML entity decoding (&amp; &lt; &gt; &quot; &apos; &#NNN; &#xHH;)
// - Collapse multiple whitespace/newlines
// - Skip <head>, <style>, <script> content entirely
@@ -547,9 +548,9 @@ private:
}
}
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, etc.)
// These appear as raw bytes in XHTML and must be mapped to ASCII
// since the e-ink font only supports ASCII characters.
// Handle UTF-8 multi-byte sequences (smart quotes, em dashes, accented chars, etc.)
// These appear as raw bytes in XHTML. Typographic chars are mapped to ASCII;
// accented Latin chars are preserved as UTF-8 for CP437 rendering on e-ink.
if ((uint8_t)c >= 0xC0) {
uint32_t codepoint = 0;
int extraBytes = 0;
@@ -579,7 +580,8 @@ private:
if (valid && extraBytes > 0) {
p += extraBytes; // Skip continuation bytes (loop increments past lead byte)
// Map Unicode codepoints to ASCII equivalents
// Map Unicode codepoints to displayable equivalents
// Typographic chars → ASCII, accented chars → preserved as UTF-8
char mapped = 0;
switch (codepoint) {
case 0x2018: case 0x2019: mapped = '\''; break; // Smart single quotes
@@ -598,6 +600,21 @@ private:
default:
if (codepoint >= 0x20 && codepoint < 0x7F) {
mapped = (char)codepoint; // Basic ASCII range
} else if (unicodeToCP437(codepoint)) {
// Accented character that the e-ink font can render via CP437.
// Preserve as UTF-8 in the output; the text reader will decode
// and map to CP437 at render time.
if (codepoint <= 0x7FF) {
output[outPos++] = 0xC0 | (codepoint >> 6);
output[outPos++] = 0x80 | (codepoint & 0x3F);
} else if (codepoint <= 0xFFFF) {
output[outPos++] = 0xE0 | (codepoint >> 12);
output[outPos++] = 0x80 | ((codepoint >> 6) & 0x3F);
output[outPos++] = 0x80 | (codepoint & 0x3F);
}
lastWasNewline = false;
lastWasSpace = false;
continue; // Already wrote to output
} else {
continue; // Skip unmappable characters
}
@@ -608,7 +625,7 @@ private:
continue; // Skip malformed UTF-8
}
} else if ((uint8_t)c >= 0x80) {
// Stray continuation byte (0x80-0xBF) skip
// Stray continuation byte (0x80-0xBF) — skip
continue;
}
@@ -683,6 +700,37 @@ private:
if (entityLen == 5 && strncmp(entity, "ldquo", 5) == 0) return '"';
if (entityLen == 5 && strncmp(entity, "rdquo", 5) == 0) return '"';
// Common accented character entities → CP437 bytes for built-in font
if (entityLen == 6 && strncmp(entity, "eacute", 6) == 0) return (char)0x82; // é
if (entityLen == 6 && strncmp(entity, "egrave", 6) == 0) return (char)0x8A; // è
if (entityLen == 5 && strncmp(entity, "ecirc", 5) == 0) return (char)0x88; // ê
if (entityLen == 4 && strncmp(entity, "euml", 4) == 0) return (char)0x89; // ë
if (entityLen == 6 && strncmp(entity, "agrave", 6) == 0) return (char)0x85; // à
if (entityLen == 6 && strncmp(entity, "aacute", 6) == 0) return (char)0xA0; // á
if (entityLen == 5 && strncmp(entity, "acirc", 5) == 0) return (char)0x83; // â
if (entityLen == 4 && strncmp(entity, "auml", 4) == 0) return (char)0x84; // ä
if (entityLen == 6 && strncmp(entity, "ccedil", 6) == 0) return (char)0x87; // ç
if (entityLen == 6 && strncmp(entity, "iacute", 6) == 0) return (char)0xA1; // í
if (entityLen == 5 && strncmp(entity, "icirc", 5) == 0) return (char)0x8C; // î
if (entityLen == 4 && strncmp(entity, "iuml", 4) == 0) return (char)0x8B; // ï
if (entityLen == 6 && strncmp(entity, "igrave", 6) == 0) return (char)0x8D; // ì
if (entityLen == 6 && strncmp(entity, "oacute", 6) == 0) return (char)0xA2; // ó
if (entityLen == 5 && strncmp(entity, "ocirc", 5) == 0) return (char)0x93; // ô
if (entityLen == 4 && strncmp(entity, "ouml", 4) == 0) return (char)0x94; // ö
if (entityLen == 6 && strncmp(entity, "ograve", 6) == 0) return (char)0x95; // ò
if (entityLen == 6 && strncmp(entity, "uacute", 6) == 0) return (char)0xA3; // ú
if (entityLen == 5 && strncmp(entity, "ucirc", 5) == 0) return (char)0x96; // û
if (entityLen == 4 && strncmp(entity, "uuml", 4) == 0) return (char)0x81; // ü
if (entityLen == 6 && strncmp(entity, "ugrave", 6) == 0) return (char)0x97; // ù
if (entityLen == 6 && strncmp(entity, "ntilde", 6) == 0) return (char)0xA4; // ñ
if (entityLen == 6 && strncmp(entity, "Eacute", 6) == 0) return (char)0x90; // É
if (entityLen == 6 && strncmp(entity, "Ccedil", 6) == 0) return (char)0x80; // Ç
if (entityLen == 6 && strncmp(entity, "Ntilde", 6) == 0) return (char)0xA5; // Ñ
if (entityLen == 4 && strncmp(entity, "Auml", 4) == 0) return (char)0x8E; // Ä
if (entityLen == 4 && strncmp(entity, "Ouml", 4) == 0) return (char)0x99; // Ö
if (entityLen == 4 && strncmp(entity, "Uuml", 4) == 0) return (char)0x9A; // Ü
if (entityLen == 5 && strncmp(entity, "szlig", 5) == 0) return (char)0xE1; // ß
// Numeric entities: &#NNN; or &#xHH;
if (entityLen >= 2 && entity[0] == '#') {
int codepoint = 0;
@@ -701,14 +749,13 @@ private:
if (ch >= '0' && ch <= '9') codepoint = codepoint * 10 + (ch - '0');
}
}
// Map to ASCII (best effort - e-ink font is ASCII only)
// Map to displayable character (best effort)
if (codepoint >= 32 && codepoint < 127) return (char)codepoint;
if (codepoint == 160) return ' '; // non-breaking space
if (codepoint == 8211 || codepoint == 8212) return '-'; // en/em dash
if (codepoint == 8216 || codepoint == 8217) return '\''; // smart quotes
if (codepoint == 8220 || codepoint == 8221) return '"'; // smart quotes
if (codepoint == 8230) return '.'; // ellipsis
if (codepoint == 8226) return '*'; // bullet
// Try CP437 mapping for accented characters.
// The byte value will be passed through to the built-in font.
uint8_t cp437 = unicodeToCP437(codepoint);
if (cp437) return (char)cp437;
// Unknown codepoint > 127: skip it
return ' ';
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h>
#include <SD.h>
#include <vector>
#include "Utf8CP437.h"
#include "EpubProcessor.h"
// Forward declarations
@@ -14,7 +15,7 @@ class UITask;
// ============================================================================
#define BOOKS_FOLDER "/books"
#define INDEX_FOLDER "/.indexes"
#define INDEX_VERSION 4
#define INDEX_VERSION 5 // v5: UTF-8 aware word wrap (accented char support)
#define PREINDEX_PAGES 100
#define READER_MAX_FILES 50
#define READER_BUF_SIZE 4096
@@ -57,6 +58,10 @@ inline WrapResult findLineBreak(const char* buffer, int bufLen, int lineStart, i
}
if (c >= 32) {
// Skip UTF-8 continuation bytes (0x80-0xBF) - the lead byte already
// counted as one display character, so don't double-count these.
if ((uint8_t)c >= 0x80 && (uint8_t)c < 0xC0) continue;
charCount++;
if (c == ' ' || c == '\t') {
if (inWord) {
@@ -855,12 +860,40 @@ private:
if (wrap.nextStart <= oldPos && wrap.lineEnd >= _pageBufLen) break;
display.setCursor(0, y);
// Print line character by character (only printable)
// Print line with UTF-8 decoding: multi-byte sequences are decoded
// to Unicode codepoints, then mapped to CP437 for the built-in font.
char charStr[2] = {0, 0};
for (int j = pos; j < wrap.lineEnd && j < _pageBufLen; j++) {
if (_pageBuf[j] >= 32) {
charStr[0] = _pageBuf[j];
int j = pos;
while (j < wrap.lineEnd && j < _pageBufLen) {
uint8_t b = (uint8_t)_pageBuf[j];
if (b < 32) {
// Control character — skip
j++;
continue;
}
if (b < 0x80) {
// Plain ASCII — print directly
charStr[0] = (char)b;
display.print(charStr);
j++;
} else if (b >= 0xC0) {
// UTF-8 lead byte — decode full sequence and map to CP437
int savedJ = j;
uint32_t cp = decodeUtf8Char(_pageBuf, wrap.lineEnd, &j);
uint8_t glyph = unicodeToCP437(cp);
if (glyph) {
charStr[0] = (char)glyph;
display.print(charStr);
}
// If unmappable (glyph==0), just skip the character
} else {
// Standalone byte 0x80-0xBF: not a valid UTF-8 lead byte.
// Treat as CP437 pass-through (e.g. from EPUB numeric entity decoding).
charStr[0] = (char)b;
display.print(charStr);
j++;
}
}
@@ -878,8 +911,9 @@ private:
display.drawRect(0, footerY - 2, display.width(), 1);
display.setColor(DisplayDriver::YELLOW);
char status[20];
sprintf(status, "%d/%d", _currentPage + 1, _totalPages);
char status[30];
int pct = _totalPages > 1 ? (_currentPage * 100) / (_totalPages - 1) : 100;
sprintf(status, "%d/%d %d%%", _currentPage + 1, _totalPages, pct);
display.setCursor(0, footerY);
display.print(status);

View File

@@ -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);

View File

@@ -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; }

View File

@@ -0,0 +1,152 @@
#pragma once
// =============================================================================
// Utf8CP437.h - UTF-8 decoding and Unicode-to-CP437 mapping
//
// The Adafruit GFX built-in 6x8 font uses the CP437 character set for codes
// 128-255. This header provides utilities to:
// 1. Decode UTF-8 multi-byte sequences into Unicode codepoints
// 2. Map Unicode codepoints to CP437 byte values for display
//
// Used by both EpubProcessor (at XHTML→text conversion time) and
// TextReaderScreen (at render time for plain .txt files).
// =============================================================================
// Map a Unicode codepoint to its CP437 equivalent byte.
// Returns the CP437 byte (0x80-0xFF) for supported accented characters,
// the codepoint itself for ASCII (0x20-0x7E), or 0 if unmappable.
inline uint8_t unicodeToCP437(uint32_t cp) {
// ASCII passthrough
if (cp >= 0x20 && cp < 0x7F) return (uint8_t)cp;
switch (cp) {
// Uppercase accented
case 0x00C7: return 0x80; // Ç
case 0x00C9: return 0x90; // É
case 0x00C4: return 0x8E; // Ä
case 0x00C5: return 0x8F; // Å
case 0x00C6: return 0x92; // Æ
case 0x00D6: return 0x99; // Ö
case 0x00DC: return 0x9A; // Ü
case 0x00D1: return 0xA5; // Ñ
// Lowercase accented
case 0x00E9: return 0x82; // é
case 0x00E2: return 0x83; // â
case 0x00E4: return 0x84; // ä
case 0x00E0: return 0x85; // à
case 0x00E5: return 0x86; // å
case 0x00E7: return 0x87; // ç
case 0x00EA: return 0x88; // ê
case 0x00EB: return 0x89; // ë
case 0x00E8: return 0x8A; // è
case 0x00EF: return 0x8B; // ï
case 0x00EE: return 0x8C; // î
case 0x00EC: return 0x8D; // ì
case 0x00E6: return 0x91; // æ
case 0x00F4: return 0x93; // ô
case 0x00F6: return 0x94; // ö
case 0x00F2: return 0x95; // ò
case 0x00FB: return 0x96; // û
case 0x00F9: return 0x97; // ù
case 0x00FF: return 0x98; // ÿ
case 0x00FC: return 0x81; // ü
case 0x00E1: return 0xA0; // á
case 0x00ED: return 0xA1; // í
case 0x00F3: return 0xA2; // ó
case 0x00FA: return 0xA3; // ú
case 0x00F1: return 0xA4; // ñ
// Currency / symbols
case 0x00A2: return 0x9B; // ¢
case 0x00A3: return 0x9C; // £
case 0x00A5: return 0x9D; // ¥
case 0x00BF: return 0xA8; // ¿
case 0x00A1: return 0xAD; // ¡
case 0x00AB: return 0xAE; // «
case 0x00BB: return 0xAF; // »
case 0x00B0: return 0xF8; // °
case 0x00B1: return 0xF1; // ±
case 0x00B5: return 0xE6; // µ
case 0x00DF: return 0xE1; // ß
// Typographic (smart quotes, dashes, etc.)
case 0x2018: case 0x2019: return '\''; // Smart single quotes
case 0x201C: case 0x201D: return '"'; // Smart double quotes
case 0x2013: case 0x2014: return '-'; // En/em dash
case 0x2010: case 0x2011: case 0x2012: case 0x2015: return '-'; // Hyphens/bars
case 0x2026: return 0xFD; // Ellipsis (CP437 has no …, use ²? no, skip)
case 0x2022: return 0x07; // Bullet → CP437 bullet
case 0x00A0: return ' '; // Non-breaking space
case 0x2039: case 0x203A: return '\''; // Single guillemets
case 0x2032: return '\''; // Prime
case 0x2033: return '"'; // Double prime
default: return 0; // Unmappable
}
}
// Decode a single UTF-8 character from a byte buffer.
// Returns the Unicode codepoint and advances *pos past the full sequence.
// If the sequence is invalid, returns 0xFFFD (replacement char) and advances by 1.
//
// buf: input buffer
// bufLen: total buffer length
// pos: pointer to current position (updated on return)
inline uint32_t decodeUtf8Char(const char* buf, int bufLen, int* pos) {
int i = *pos;
if (i >= bufLen) return 0;
uint8_t c = (uint8_t)buf[i];
// ASCII (single byte)
if (c < 0x80) {
*pos = i + 1;
return c;
}
// Continuation byte without lead byte — skip
if (c < 0xC0) {
*pos = i + 1;
return 0xFFFD;
}
uint32_t codepoint;
int extraBytes;
if ((c & 0xE0) == 0xC0) {
codepoint = c & 0x1F;
extraBytes = 1;
} else if ((c & 0xF0) == 0xE0) {
codepoint = c & 0x0F;
extraBytes = 2;
} else if ((c & 0xF8) == 0xF0) {
codepoint = c & 0x07;
extraBytes = 3;
} else {
*pos = i + 1;
return 0xFFFD;
}
// Verify we have enough bytes and they're valid continuation bytes
if (i + extraBytes >= bufLen) {
*pos = i + 1;
return 0xFFFD;
}
for (int b = 1; b <= extraBytes; b++) {
uint8_t cb = (uint8_t)buf[i + b];
if ((cb & 0xC0) != 0x80) {
*pos = i + 1;
return 0xFFFD;
}
codepoint = (codepoint << 6) | (cb & 0x3F);
}
*pos = i + 1 + extraBytes;
return codepoint;
}
// Check if a byte is a UTF-8 continuation byte (10xxxxxx)
inline bool isUtf8Continuation(uint8_t c) {
return (c & 0xC0) == 0x80;
}

View File

@@ -28,7 +28,10 @@ private:
uint8_t _addr;
TwoWire* _wire;
bool _initialized;
bool _shiftActive; // Sticky shift (one-shot)
bool _shiftActive; // Sticky shift (one-shot or held)
bool _shiftConsumed; // Was shift active for the last returned key
bool _shiftHeld; // Shift key physically held down
bool _shiftUsedWhileHeld; // Was shift consumed by any key while held
bool _altActive; // Sticky alt (one-shot)
bool _symActive; // Sticky sym (one-shot)
unsigned long _lastShiftTime; // For Shift+key combos
@@ -148,7 +151,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), _shiftHeld(false), _shiftUsedWhileHeld(false), _altActive(false), _symActive(false), _lastShiftTime(0) {}
bool begin() {
// Check if device responds
@@ -203,6 +206,19 @@ public:
Serial.printf("KB raw: event=0x%02X code=%d pressed=%d count=%d\n",
keyEvent, keyCode, pressed, keyCount);
// Track shift release (before the general release-ignore)
if (!pressed && (keyCode == 35 || keyCode == 31)) {
_shiftHeld = false;
// If shift was used while held (e.g. cursor nav), clear it completely
// so the next bare keypress isn't treated as shifted.
// If shift was NOT used (tap-then-release), keep _shiftActive for one-shot.
if (_shiftUsedWhileHeld) {
_shiftActive = false;
}
_shiftUsedWhileHeld = false;
return 0;
}
// Only act on key press, not release
if (!pressed || keyCode == 0) {
return 0;
@@ -211,6 +227,8 @@ public:
// Handle modifier keys - set sticky state and return 0
if (keyCode == 35 || keyCode == 31) { // Shift keys
_shiftActive = true;
_shiftHeld = true;
_shiftUsedWhileHeld = false;
_lastShiftTime = millis();
Serial.println("KB: Shift activated");
return 0;
@@ -276,7 +294,17 @@ public:
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
_shiftActive = false; // Reset sticky shift
// Track that shift was used while physically held
if (_shiftHeld) {
_shiftUsedWhileHeld = true;
}
// Only clear shift if it's one-shot (tap), not held down
if (!_shiftHeld) {
_shiftActive = false;
}
_shiftConsumed = true; // Record that shift was active for this key
} else {
_shiftConsumed = false;
}
if (c != 0) {
@@ -294,4 +322,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;
}
};