From 3363867d9890437aaf7a20ef855dd07995f58220 Mon Sep 17 00:00:00 2001 From: marcin Date: Thu, 2 Jul 2026 12:21:52 +0200 Subject: [PATCH] Added T9 on-screen keyboard as an option --- examples/companion_radio/DataStore.cpp | 4 + examples/companion_radio/NodePrefs.h | 7 +- .../companion_radio/ui-new/KeyboardWidget.h | 142 ++++++++++++++---- .../companion_radio/ui-new/SettingsScreen.h | 16 +- examples/companion_radio/ui-new/UITask.cpp | 1 + 5 files changed, 141 insertions(+), 29 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index b8a5ad7e..548d5130 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -428,6 +428,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no if (_prefs.alarm_on > 1) _prefs.alarm_on = 0; if (_prefs.alarm_hour > 23) _prefs.alarm_hour = 0; if (_prefs.alarm_min > 59) _prefs.alarm_min = 0; + // → 0xC0DE0018: keyboard type (QWERTY/T9). + rd(&_prefs.keyboard_type, sizeof(_prefs.keyboard_type)); + if (_prefs.keyboard_type > 1) _prefs.keyboard_type = 0; // Pre-0x10 files leave stray sentinel bytes here, same as a never-configured // device. Either way there's no valid saved profile, so default to a profile // in the same band as the companion's own network (_prefs.freq, already read @@ -632,6 +635,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.alarm_on, sizeof(_prefs.alarm_on)); file.write((uint8_t *)&_prefs.alarm_hour, sizeof(_prefs.alarm_hour)); file.write((uint8_t *)&_prefs.alarm_min, sizeof(_prefs.alarm_min)); + file.write((uint8_t *)&_prefs.keyboard_type, sizeof(_prefs.keyboard_type)); // Tail sentinel — must be last. See NodePrefs::SCHEMA_SENTINEL. Its write is // the one we check: once the flash fills, writes return 0, so a good diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 972392a5..e3805c84 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -282,6 +282,11 @@ struct NodePrefs { // persisted to file uint8_t alarm_hour; // 0-23, local time uint8_t alarm_min; // 0-59 + // On-screen keyboard layout, shared across every text-entry screen (Settings > + // Keyboard). 0=QWERTY grid (default), 1=T9 multi-tap (phone-keypad groups, + // cycled with repeated Enter presses — see KeyboardWidget.h). + uint8_t keyboard_type; + // Single source of truth for the live-share option tables (shared by the Map // UI labels and the auto-send engine in UITask). static const uint8_t LOC_SHARE_MOVE_COUNT = 4; @@ -344,7 +349,7 @@ struct NodePrefs { // persisted to file // adding/removing/reordering fields in DataStore::savePrefs/loadPrefsInt so // older saves are detected on load and skipped (zero-init defaults kept). // High 24 bits identify the file format; low byte is the schema revision. - static const uint32_t SCHEMA_SENTINEL = 0xC0DE0017; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0018; // Bit-index for each home page. Used by page_order (entries store bit+1) and // by home_pages_mask. Single source of truth — both HomeScreen::pageBit/bitToPage diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 580e6551..fb571b5a 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -4,6 +4,7 @@ #include #include "PopupMenu.h" #include "icons.h" // mini-icons for the special-key row (⇧ ⌫ ⎵ ✓) +#include "../NodePrefs.h" // Layout constants shared by all keyboard users. // Two pages: letters (page 0) and symbols (page 1), toggled by the "#@"/"abc" @@ -27,6 +28,20 @@ static const char KB_CHARS[KB_PAGES][4][10] = { static const int KB_ROWS_CHAR = 4; static const int KB_COLS_CHAR = 10; static const int KB_SPECIAL = 6; // ⇧ ⎵ ⌫ {} #@/abc ✓ + +// T9 multi-tap layout (Settings › Keyboard). A classic phone keypad: 9 cells (keys +// 1-9) laid out 3x3, each holding a handful of letters/symbols. Repeated Enter +// presses on the same cell within KB_T9_TIMEOUT_MS cycle through the group, ending +// on the cell's own digit (computed as '1'+cell, not stored here) before wrapping. +// Keys 0/*/# aren't part of the grid — space/backspace/etc. already live on the +// special row below, shared with the QWERTY layout. +static const int KB_T9_ROWS = 3; +static const int KB_T9_COLS = 3; +static const uint32_t KB_T9_TIMEOUT_MS = 800; +static const char* const KB_T9_GROUPS[KB_PAGES][9] = { + { ".,!?'-", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz" }, // page 0 — letters + { "@#&", "*()", "-_+", "=/\\", ":;'\"", "<>[]", "{}|~", "^$%`", ",." }, // page 1 — symbols +}; // Buffer cap for typed text, in bytes. Matches MeshCore's MAX_TEXT_LEN // (10*CIPHER_BLOCK_SIZE = 160) so a full-length message can be composed; each // field passes its own smaller max to begin() where its store is smaller. @@ -51,6 +66,21 @@ struct KeyboardWidget { int _ph_count; PopupMenu _ph_menu; + // Live setting lookup — set once by UITask::begin(). NULL only in tests/tools + // that construct a KeyboardWidget standalone, in which case isT9() defaults + // to QWERTY. + NodePrefs* prefs = nullptr; + bool isT9() const { return prefs && prefs->keyboard_type == 1; } + + // T9 multi-tap state: which grid cell is mid-cycle (-1 = none), its cycle + // position, and when the last Enter landed on it (for the timeout). + int t9_cell = -1; + int t9_cycle = 0; + uint32_t t9_last_ms = 0; + + int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; } + int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; } + enum Result { NONE, DONE, CANCELLED }; void begin(const char* initial = "", int max = KB_MAX_LEN) { @@ -61,6 +91,8 @@ struct KeyboardWidget { row = col = 0; page = 0; caps = false; + t9_cell = -1; + t9_cycle = 0; _ph_menu.active = false; // default placeholders — always available _ph_count = 0; @@ -79,20 +111,27 @@ struct KeyboardWidget { } int render(DisplayDriver& display) { + // A stale mid-cycle T9 press (no further input since) finalizes on its own — + // the character is already committed to buf, this just stops a later Enter + // on the same cell from being treated as a continued cycle. + if (t9_cell >= 0 && millis() - t9_last_ms > KB_T9_TIMEOUT_MS) t9_cell = -1; + display.setTextSize(1); display.setColor(DisplayDriver::LIGHT); + const int rows = gridRows(); + const int cols = gridCols(); const int lh = display.getLineHeight(); const int cw = display.getCharWidth(); - const int cell_w = display.width() / KB_COLS_CHAR; + const int cell_w = display.width() / cols; // compact: don't stretch cells beyond lh; freed vertical space goes to preview lines - const int kb_h = (KB_ROWS_CHAR + 1) * lh; + const int kb_h = (rows + 1) * lh; const int preview_h = display.height() - kb_h - display.sepH(); const int prev_lines = (preview_h / lh) > 1 ? (preview_h / lh) : 1; const int sep_y = prev_lines * lh; const int chars_y = sep_y + display.sepH(); - const int cell_h = (display.height() - chars_y) / (KB_ROWS_CHAR + 1); - const int spec_y = chars_y + KB_ROWS_CHAR * cell_h; + const int cell_h = (display.height() - chars_y) / (rows + 1); + const int spec_y = chars_y + rows * cell_h; const int spec_w = display.width() / KB_SPECIAL; // multi-line text preview: cursor always on last preview line @@ -124,17 +163,36 @@ struct KeyboardWidget { display.fillRect(0, sep_y, display.width(), display.sepH()); // character grid - for (int r = 0; r < KB_ROWS_CHAR; r++) { - int y = chars_y + r * cell_h; - for (int c = 0; c < KB_COLS_CHAR; c++) { - bool sel = (row == r && col == c); - char ch = KB_CHARS[page][r][c]; - if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; - char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; - int cx = c * cell_w; - display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); - display.setCursor(cx + (cell_w - cw) / 2, y); - display.print(ch_buf); + if (isT9()) { + for (int r = 0; r < rows; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < cols; c++) { + bool sel = (row == r && col == c); + int cell = r * cols + c; + char group[10]; + strncpy(group, KB_T9_GROUPS[page][cell], sizeof(group) - 1); + group[sizeof(group) - 1] = '\0'; + if (caps) for (char* p = group; *p; p++) if (*p >= 'a' && *p <= 'z') *p = *p - 'a' + 'A'; + int cx = c * cell_w; + display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); + int tw = display.getTextWidth(group); + display.setCursor(cx + (cell_w - tw) / 2, y); + display.print(group); + } + } + } else { + for (int r = 0; r < rows; r++) { + int y = chars_y + r * cell_h; + for (int c = 0; c < cols; c++) { + bool sel = (row == r && col == c); + char ch = KB_CHARS[page][r][c]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + char ch_buf[2] = { ch == ' ' ? '_' : ch, '\0' }; + int cx = c * cell_w; + display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); + display.setCursor(cx + (cell_w - cw) / 2, y); + display.print(ch_buf); + } } } @@ -142,7 +200,7 @@ struct KeyboardWidget { const int s = miniIconScale(display); const int icy = spec_y + (cell_h - lh) / 2; // centre icons within the cell for (int i = 0; i < KB_SPECIAL; i++) { - bool sel = (row == KB_ROWS_CHAR && col == i); + bool sel = (row == rows && col == i); bool active = (i == 0 && caps); int sx = i * spec_w; display.drawSelectionRow(sx, spec_y - 1, spec_w - 1, cell_h, sel || active); @@ -191,40 +249,69 @@ struct KeyboardWidget { if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) return CANCELLED; + const int rows = gridRows(); + const int cols = gridCols(); + if (c == KEY_UP) { if (row > 0) { row--; - if (row == KB_ROWS_CHAR - 1) // leaving special row upward - col = col * KB_COLS_CHAR / KB_SPECIAL; + if (row == rows - 1) // leaving special row upward + col = col * cols / KB_SPECIAL; } else { - row = KB_ROWS_CHAR; // wrap up onto the special row - col = col * KB_SPECIAL / KB_COLS_CHAR; + row = rows; // wrap up onto the special row + col = col * KB_SPECIAL / cols; } + t9_cell = -1; // navigating away finalizes any pending multi-tap cycle return NONE; } if (c == KEY_DOWN) { - if (row < KB_ROWS_CHAR) { + if (row < rows) { row++; - if (row == KB_ROWS_CHAR) // entering special row - col = col * KB_SPECIAL / KB_COLS_CHAR; + if (row == rows) // entering special row + col = col * KB_SPECIAL / cols; } else { row = 0; // wrap down onto the first char row - col = col * KB_COLS_CHAR / KB_SPECIAL; + col = col * cols / KB_SPECIAL; } + t9_cell = -1; return NONE; } if (c == KEY_LEFT) { - int max_col = (row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + int max_col = (row == rows) ? KB_SPECIAL - 1 : cols - 1; col = (col > 0) ? col - 1 : max_col; + t9_cell = -1; return NONE; } if (c == KEY_RIGHT) { - int max_col = (row == KB_ROWS_CHAR) ? KB_SPECIAL - 1 : KB_COLS_CHAR - 1; + int max_col = (row == rows) ? KB_SPECIAL - 1 : cols - 1; col = (col < max_col) ? col + 1 : 0; + t9_cell = -1; return NONE; } if (c == KEY_ENTER) { - if (row < KB_ROWS_CHAR) { + if (row < rows && isT9()) { + int cell = row * cols + col; + const char* group = KB_T9_GROUPS[page][cell]; + int glen = (int)strlen(group); + int total = glen + 1; // + the cell's own digit, at the end of the cycle + bool cycling = (t9_cell == cell) && (millis() - t9_last_ms < KB_T9_TIMEOUT_MS); + if (cycling) { + t9_cycle = (t9_cycle + 1) % total; + if (len > 0) { + char ch = (t9_cycle < glen) ? group[t9_cycle] : (char)('1' + cell); + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + buf[len - 1] = ch; + } + } else if (len < max_len) { + char ch = group[0]; + if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; + buf[len++] = ch; + buf[len] = '\0'; + t9_cell = cell; + t9_cycle = 0; + } + t9_last_ms = millis(); + } else if (row < rows) { if (len < max_len) { char ch = KB_CHARS[page][row][col]; if (caps && ch >= 'a' && ch <= 'z') ch = ch - 'a' + 'A'; @@ -232,6 +319,7 @@ struct KeyboardWidget { buf[len] = '\0'; } } else { + t9_cell = -1; // any special-row action finalizes a pending multi-tap cycle switch (col) { case 0: caps = !caps; break; case 1: diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index b8322acf..26cc8e2f 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -67,6 +67,9 @@ class SettingsScreen : public UIScreen { TIMEZONE, LOW_BAT, UNITS, + // Keyboard section + SECTION_KEYBOARD, + KEYBOARD_TYPE, // Contacts section SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, // Messages section @@ -85,7 +88,7 @@ class SettingsScreen : public UIScreen { bool _dirty = false; AccordionList _acc; - static const int NUM_SECTIONS = 7; + static const int NUM_SECTIONS = 8; static const int MAX_PER_SEC = 16; uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row) uint8_t _sec_count[NUM_SECTIONS]; @@ -168,6 +171,7 @@ class SettingsScreen : public UIScreen { return item == SECTION_DISPLAY || item == SECTION_SOUND || item == SECTION_HOME_PAGES || item == SECTION_RADIO || item == SECTION_SYSTEM || + item == SECTION_KEYBOARD || item == SECTION_CONTACTS || item == SECTION_MESSAGES; } @@ -177,6 +181,7 @@ class SettingsScreen : public UIScreen { if (item == SECTION_HOME_PAGES) return "Home Pages"; if (item == SECTION_RADIO) return "Radio"; if (item == SECTION_SYSTEM) return "System"; + if (item == SECTION_KEYBOARD) return "Keyboard"; if (item == SECTION_CONTACTS) return "Contacts"; if (item == SECTION_MESSAGES) return "Messages"; return ""; @@ -561,6 +566,10 @@ class SettingsScreen : public UIScreen { display.print("Units"); display.setCursor(valCol(display), y); display.print((p && p->units_imperial) ? "Imperial" : "Metric"); + } else if (item == KEYBOARD_TYPE) { + display.print("Type"); + display.setCursor(valCol(display), y); + display.print((p && p->keyboard_type) ? "T9" : "QWERTY"); } else if (item == BATT_DISPLAY) { display.print("BattDisp"); display.setCursor(valCol(display), y); @@ -884,6 +893,11 @@ public: _dirty = true; return true; } + if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) { + p->keyboard_type ^= 1; + _dirty = true; + return true; + } if (_selected == DM_RESEND && p) { int n = p->dm_resend_count; if (right || enter) n = (n + 1) % 6; // 0..5, wraps diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 5e5a28b7..3a79764a 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1423,6 +1423,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no _display = display; _sensors = sensors; _node_prefs = node_prefs; + _kb.prefs = node_prefs; uint32_t aoff = autoOffMillis(); _auto_off = millis() + (aoff > 0 ? aoff : AUTO_OFF_MILLIS);