mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
Merge branch 'integrate/pr21-t9-keyboard'
Merges PR #21 (T9 on-screen keyboard option, marczykm) plus a follow-up fix bumping SCHEMA_SENTINEL to 0xC0DE001A — the PR added a new persisted field (keyboard_type) without bumping the schema version, which would have silently misaligned every NodePrefs field after alarm_min for devices already on the previous schema.
This commit is contained in:
@@ -428,6 +428,10 @@ 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;
|
||||
// → 0xC0DE001A: keyboard type (QWERTY/T9). Pre-0x1A files leave stray sentinel
|
||||
// tail bytes here; clamp back to the QWERTY default (0).
|
||||
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
|
||||
@@ -652,6 +656,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));
|
||||
// page_order tail slots (see loadPrefsInt): entries beyond PAGE_ORDER_LEN_V1,
|
||||
// appended here so the on-disk head stays the original 11 bytes.
|
||||
file.write((uint8_t *)&_prefs.page_order[NodePrefs::PAGE_ORDER_LEN_V1],
|
||||
|
||||
@@ -284,6 +284,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;
|
||||
@@ -346,7 +351,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 = 0xC0DE0019;
|
||||
static const uint32_t SCHEMA_SENTINEL = 0xC0DE001A;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <Arduino.h>
|
||||
#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:
|
||||
|
||||
@@ -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 "";
|
||||
@@ -549,6 +554,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);
|
||||
@@ -872,6 +881,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
|
||||
|
||||
@@ -1425,6 +1425,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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user