Files
MeshCore-Solo/examples/companion_radio/ui-new/KeyboardWidget.h
2026-07-02 12:21:52 +02:00

346 lines
13 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#pragma once
#include <helpers/ui/DisplayDriver.h>
#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"
// special key. Space lives only on the ⎵ special key now, so the freed grid
// slot on page 0 holds the comma; punctuation is grouped as . , ! ?
static const int KB_PAGES = 2;
static const char KB_CHARS[KB_PAGES][4][10] = {
{ // page 0 — letters + digits
{'a','b','c','d','e','f','g','h','i','j'},
{'k','l','m','n','o','p','q','r','s','t'},
{'u','v','w','x','y','z','.',',','!','?'},
{'1','2','3','4','5','6','7','8','9','0'},
},
{ // page 1 — symbols + digits (ASCII only — one byte per key)
{'@','#','&','*','(',')','-','_','+','='},
{'/','\\',':',';','\'','"','<','>','[',']'},
{'{','}','|','~','^','$','%','`',',','.'},
{'1','2','3','4','5','6','7','8','9','0'},
},
};
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.
static const int KB_MAX_LEN = 160;
// Longest preview line we render per row. Caps the per-line stack buffers so a
// very wide display (small font → many chars per line) can't overrun them.
static const int KB_PREVIEW_CAP = 46;
static const int KB_PH_MAX = 12; // max placeholders in list
static const int KB_PH_LEN = 9; // max placeholder string length incl. null
static const int KB_PH_VISIBLE = 3; // items shown at once in overlay
struct KeyboardWidget {
char buf[KB_MAX_LEN + 1];
int len;
int max_len;
int row, col;
int page; // 0 = letters, 1 = symbols
bool caps;
char _ph_buf[KB_PH_MAX][KB_PH_LEN];
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) {
max_len = (max > KB_MAX_LEN) ? KB_MAX_LEN : max;
strncpy(buf, initial, max_len);
buf[max_len] = '\0';
len = strlen(buf);
row = col = 0;
page = 0;
caps = false;
t9_cell = -1;
t9_cycle = 0;
_ph_menu.active = false;
// default placeholders — always available
_ph_count = 0;
addPlaceholder("{loc}");
addPlaceholder("{time}");
}
void clearPlaceholders() { _ph_count = 0; }
void addPlaceholder(const char* ph) {
if (_ph_count < KB_PH_MAX) {
strncpy(_ph_buf[_ph_count], ph, KB_PH_LEN - 1);
_ph_buf[_ph_count][KB_PH_LEN - 1] = '\0';
_ph_count++;
}
}
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() / cols;
// compact: don't stretch cells beyond lh; freed vertical space goes to preview lines
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) / (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
int cpl = display.width() / cw; // chars per preview line
if (cpl < 1) cpl = 1;
if (cpl > KB_PREVIEW_CAP) cpl = KB_PREVIEW_CAP; // never overrun linebuf below
int cursor_line = len / cpl;
int first_line = (cursor_line >= prev_lines) ? (cursor_line - prev_lines + 1) : 0;
int start = first_line * cpl;
for (int pl = 0; pl < prev_lines; pl++) {
int ps = start + pl * cpl;
int pe = ps + cpl;
bool cursor_here = (ps <= len && (len < pe || pl == prev_lines - 1));
char linebuf[KB_PREVIEW_CAP + 2]; // cpl chars + cursor '_' + NUL
if (cursor_here) {
int nc = len - ps; if (nc < 0) nc = 0; if (nc > cpl - 1) nc = cpl - 1;
snprintf(linebuf, sizeof(linebuf), "%.*s_", nc, buf + ps);
} else if (len > ps) {
int nc = (len < pe) ? (len - ps) : cpl;
snprintf(linebuf, sizeof(linebuf), "%.*s", nc, buf + ps);
} else {
linebuf[0] = '\0';
}
char linebuf_t[KB_PREVIEW_CAP + 2];
display.translateUTF8ToBlocks(linebuf_t, linebuf, sizeof(linebuf_t));
display.setCursor(0, pl * lh);
display.print(linebuf_t);
}
display.fillRect(0, sep_y, display.width(), display.sepH());
// character grid
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);
}
}
}
// special row: caps ⇧ · space ⎵ · delete ⌫ · placeholders {} (text) · OK ✓
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 == 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);
if (i == 3 || i == 4) { // text keys: {} picker, page toggle
const char* lbl = (i == 3) ? "{}" : (page == 0 ? "#@" : "abc");
int tw = display.getTextWidth(lbl);
display.setCursor(sx + (spec_w - tw) / 2, spec_y);
display.print(lbl);
} else if (i == 1) { // space ⎵ — two halves side by side
int icw = (ICON_SPACE_L.w + ICON_SPACE_R.w) * s;
int ix = sx + (spec_w - icw) / 2;
miniIconDraw(display, ix, icy, ICON_SPACE_L);
miniIconDraw(display, ix + ICON_SPACE_L.w * s, icy, ICON_SPACE_R);
} else {
const MiniIcon& ic = (i == 0) ? ICON_SHIFT
: (i == 2) ? ICON_BACKSPACE
: ICON_CHECK; // i == 5 → OK
int ix = sx + (spec_w - ic.w * s) / 2;
miniIconDraw(display, ix, icy, ic);
}
display.setColor(DisplayDriver::LIGHT);
}
// placeholder picker overlay (drawn on top of keyboard)
if (_ph_menu.active) _ph_menu.render(display);
return 50;
}
Result handleInput(char c) {
// placeholder overlay consumes all input
if (_ph_menu.active) {
auto res = _ph_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int idx = _ph_menu.selectedIndex();
const char* ph = _ph_buf[idx];
int ph_len = strlen(ph);
if (len + ph_len <= max_len) {
memcpy(buf + len, ph, ph_len);
len += ph_len;
buf[len] = '\0';
}
}
return NONE;
}
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 == rows - 1) // leaving special row upward
col = col * cols / KB_SPECIAL;
} else {
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 < rows) {
row++;
if (row == rows) // entering special row
col = col * KB_SPECIAL / cols;
} else {
row = 0; // wrap down onto the first char row
col = col * cols / KB_SPECIAL;
}
t9_cell = -1;
return NONE;
}
if (c == KEY_LEFT) {
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 == rows) ? KB_SPECIAL - 1 : cols - 1;
col = (col < max_col) ? col + 1 : 0;
t9_cell = -1;
return NONE;
}
if (c == KEY_ENTER) {
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';
buf[len++] = ch;
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:
if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; }
break;
case 2:
if (len > 0) buf[--len] = '\0';
break;
case 3:
_ph_menu.begin("Placeholder:", KB_PH_VISIBLE);
for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]);
break;
case 4:
page ^= 1; // toggle letters ⇄ symbols
break;
case 5:
return DONE;
}
}
}
return NONE;
}
};