#pragma once #include #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" // 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 ABC 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 }; // Additional (non-Latin) keyboard alphabets — NodePrefs::keyboard_alt_alphabet // picks which one (if any) joins the Latin/symbols page cycle. Every alphabet // here must fall inside the misc-fixed font's U+0020-04FF range // (src/helpers/ui/MiscFixedFont.h) since that's what actually draws these // glyphs on-screen. // // Unlike KB_CHARS (single ASCII byte per cell), these hold UTF-8 strings — // Cyrillic is 2 bytes/codepoint — so cells are `const char*`, not `char`. // KeyboardWidget's insertion/backspace/T9-cycle logic works in codepoints via // the kbUtf8*() helpers below, not raw bytes, to stay correct for either. // Cyrillic ABC grid: rows 0-2 hold the 30 letters that fit alphabetically // (а-э); row 3 holds the remaining 3 (ё, ю, я) plus basic punctuation. No // digit row on this page (digits are already reachable via the Symbols page, // same as the Latin page's own row 3 duplicates them there). static const char* const KB_CYRILLIC_CHARS[4][10] = { { "а","б","в","г","д","е","ж","з","и","й" }, { "к","л","м","н","о","п","р","с","т","у" }, { "ф","х","ц","ч","ш","щ","ъ","ы","ь","э" }, { "ю","я","ё",".",",","!","?","-","'","\"" }, }; // Cyrillic T9 groups: the classic Russian phone-keypad distribution. Cell 0 // (digit '1') is punctuation, matching KB_T9_GROUPS' page-0 convention; // cells 1-8 (digits '2'-'9') hold the 33 letters, 3-5 per key. static const char* const KB_T9_GROUPS_CYRILLIC[9] = { ".,!?'-", "абвг", "деёжз", "ийкл", "мноп", "рсту", "фхцч", "шщъыь", "эюя" }; // Greek ABC grid: the 24-letter modern alphabet plus final sigma (ς, used // only at the end of a word — σ is the regular form) = 25 letters, fitting // rows 0-2 with 5 basic punctuation marks to spare; row 3 keeps the digit row // (unlike Cyrillic or most of the Latin-diacritic alphabets below, Greek has // room left over in its own letter rows). // NOTE: monotonic Modern Greek normally marks stress with a tonos accent // (ά έ ή ί ό ύ ώ) — omitted here to keep this a single, simple page. Fine for // informal/transliteration-style typing; flag if proper accented Greek // composition turns out to matter and we can add a second Greek page for them. static const char* const KB_GREEK_CHARS[4][10] = { { "α","β","γ","δ","ε","ζ","η","θ","ι","κ" }, { "λ","μ","ν","ξ","ο","π","ρ","σ","ς","τ" }, { "υ","φ","χ","ψ","ω",".",",","!","?","'" }, { "1","2","3","4","5","6","7","8","9","0" }, }; // Greek T9 groups: cell 0 (digit '1') is punctuation; cells 1-8 (digits // '2'-'9') split the 25 letters roughly evenly, final sigma grouped with the // regular sigma it's a variant of. static const char* const KB_T9_GROUPS_GREEK[9] = { ".,!?'-", "αβγ", "δεζ", "ηθι", "κλμ", "νξο", "πρσς", "τυφ", "χψω" }; // Per-language Latin-diacritic ABC grids. Each is that language's own full // set of non-ASCII letters (not a shared curated subset — see NodePrefs.h's // KB_ALPHABET_POLISH..KB_ALPHABET_NORDIC doc comment for why these replaced a // single combined "Ext.Latin" page). Every grid is filled to all 40 cells the // same way: letters first, then punctuation to round out the last letter // row, then a full digit row, then any further rows filled with more // punctuation/symbols — consistent shape regardless of how few letters a // given language actually needs (German's 4, at the small end, still gets a // full page rather than mostly-blank cells). static const char* const KB_POLISH_CHARS[4][10] = { { "ą", "ć", "ę", "ł", "ń", "ó", "ś", "ź", "ż", "." }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { ",", "!", "?", "'", "-", "\"", ":", ";", "(", ")" }, { "@", "#", "&", "*", "_", "+", "=", "/", "\\", "~" }, }; static const char* const KB_T9_GROUPS_POLISH[9] = { ".,!?'-", "ąć", "ęł", "ńó", "śź", "ż", ";()", "@#&*", "_+=/" }; static const char* const KB_CZECH_CHARS[4][10] = { { "á", "č", "ď", "é", "ě", "í", "ň", "ó", "ř", "š" }, { "ť", "ú", "ů", "ý", "ž", ".", ",", "!", "?", "'" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" }, }; static const char* const KB_T9_GROUPS_CZECH[9] = { ".,!?'-", "áč", "ďé", "ěí", "ňó", "řš", "ťú", "ůý", "ž" }; // Slovak overlaps Czech heavily (both descend from a shared diacritic // tradition) but adds ä/ĺ/ľ/ô/ŕ and drops none — kept as its own page rather // than merged, per the "each language separate" request. static const char* const KB_SLOVAK_CHARS[4][10] = { { "á", "ä", "č", "ď", "é", "í", "ĺ", "ľ", "ň", "ó" }, { "ô", "ŕ", "š", "ť", "ú", "ý", "ž", ".", ",", "!" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "?", "'", "-", "\"", ":", ";", "(", ")", "@", "#" }, }; static const char* const KB_T9_GROUPS_SLOVAK[9] = { ".,!?'-", "áäč", "ďé", "íĺ", "ľň", "óô", "ŕš", "ťú", "ýž" }; // German: only 4 non-ASCII letters (ä ö ü ß) — the smallest of these // keyboards, still filled to a full page rather than left sparse. static const char* const KB_GERMAN_CHARS[4][10] = { { "ä", "ö", "ü", "ß", ".", ",", "!", "?", "'", "-" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "\"", ":", ";", "(", ")", "@", "#", "&", "*", "_" }, { "+", "=", "/", "\\", "~", "<", ">", "[", "]", "{" }, }; static const char* const KB_T9_GROUPS_GERMAN[9] = { ".,!?'-", "äö", "üß", ";()", "@#&*", "_+=/", "\\~<>", "[]{}", "|^$%" }; // French: à â ç é è ê ë î ï ô ù û ü ÿ œ. ÿ's uppercase (Ÿ, U+0178) is the one // case-shift exception outside the Latin-1 flat -0x20 rule — see // kbApplyCapsUtf8. œ is Latin Extended-A (adjacent-pair rule, verified). static const char* const KB_FRENCH_CHARS[4][10] = { { "à", "â", "ç", "é", "è", "ê", "ë", "î", "ï", "ô" }, { "ù", "û", "ü", "ÿ", "œ", ".", ",", "!", "?", "'" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" }, }; static const char* const KB_T9_GROUPS_FRENCH[9] = { ".,!?'-", "àâ", "çé", "èê", "ëî", "ïô", "ùû", "üÿ", "œ" }; // Spanish: the 5 accented vowels, ñ, and ü (only appears in güe/güi). static const char* const KB_SPANISH_CHARS[4][10] = { { "á", "é", "í", "ñ", "ó", "ú", "ü", ".", ",", "!" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "?", "'", "-", "\"", ":", ";", "(", ")", "@", "#" }, { "&", "*", "_", "+", "=", "/", "\\", "~", "<", ">" }, }; static const char* const KB_T9_GROUPS_SPANISH[9] = { ".,!?'-", "áé", "íñ", "óú", "ü", ";()", "@#&*", "_+=/", "\\~<>" }; // Portuguese: á à â ã ç é ê í ó ô õ ú (ü dropped from modern orthography). static const char* const KB_PORTUGUESE_CHARS[4][10] = { { "á", "à", "â", "ã", "ç", "é", "ê", "í", "ó", "ô" }, { "õ", "ú", ".", ",", "!", "?", "'", "-", "\"", ":" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { ";", "(", ")", "@", "#", "&", "*", "_", "+", "=" }, }; static const char* const KB_T9_GROUPS_PORTUGUESE[9] = { ".,!?'-", "áà", "âã", "çé", "êí", "óô", "õú", ";()", "@#&*" }; // Nordic: å ä æ ö ø — shared across Danish/Norwegian/Swedish (their // alphabets differ only in which of these 5 each uses), so kept as one page // rather than three near-identical ones. static const char* const KB_NORDIC_CHARS[4][10] = { { "å", "ä", "æ", "ö", "ø", ".", ",", "!", "?", "'" }, { "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }, { "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" }, { "_", "+", "=", "/", "\\", "~", "<", ">", "[", "]" }, }; static const char* const KB_T9_GROUPS_NORDIC[9] = { ".,!?'-", "åä", "æö", "ø", ";()", "@#&*", "_+=/", "\\~<>", "[]{}" }; // 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; // ── UTF-8 helpers for the alt-alphabet pages ───────────────────────────────── // KB_CHARS/KB_T9_GROUPS content is ASCII (1 byte/char); KB_CYRILLIC_CHARS and // KB_T9_GROUPS_CYRILLIC are UTF-8 (2 bytes/char in this range). These helpers // let insertion, backspace and T9 cycling work in codepoints for either, // instead of assuming 1 byte == 1 character. // Apply Shift/caps to every codepoint in a UTF-8 string, writing the result // (same codepoint count, each independently shifted) into `out`. Every script // here pairs lower/uppercase differently, so each gets its own rule: // - ASCII a-z and Cyrillic а-я: flat -0x20 codepoint offset. ё/Ё (U+0451/ // U+0401) break the Cyrillic pattern by 0x50 and are special-cased. // - Latin-1 Supplement à-þ (U+00E0-00FE, used by every Latin-diacritic // alphabet below — ö/ü/é/á/etc.): also a flat -0x20 offset, same as ASCII — // the block was designed as parallel case pairs. U+00F7 (÷, division sign) // sits in that numeric range but isn't a letter; excluded. ß (U+00DF) has // no simple uppercase in this range (its uppercase ẞ is U+1E9E, outside // Lemon's U+0020-04FF) — left as-is. ÿ (U+00FF, French) is the one letter // in this block whose uppercase Ÿ (U+0178) falls outside it entirely — // special-cased before the range rule. // - Latin Extended-A (ą/č/ĺ/œ/etc., U+0100-017F): NOT a flat offset like // Latin-1 — this block alternates even=uppercase/odd=lowercase in adjacent // pairs, so lowercase - 1 = uppercase. Verified true (against Python's own // str.upper()) for every character actually used across the Polish/Czech/ // Slovak/French keyboards below; NOT a universal rule for the whole block // (it has a handful of unpaired/irregular codepoints elsewhere) — recheck // before adding more from it. // - Greek α-ω (U+03B1-03C9): flat -0x20 offset, same shape as Cyrillic/ASCII. // Final sigma ς (U+03C2) is the one exception — it has no uppercase of its // own; -0x20 would land on U+03A2, which is unassigned. It capitalizes to // regular Σ (U+03A3) instead, same as σ, and is special-cased before the // general range rule (03C2 falls inside 03B1-03C9, so order matters here). // Used both for a single cell (one codepoint) and a whole T9 group label/string. static void kbApplyCapsUtf8(const char* in, bool caps, char* out, size_t out_size) { size_t o = 0; const uint8_t* p = (const uint8_t*)in; while (*p && o + 2 < out_size) { uint32_t cp = DisplayDriver::decodeCodepoint(p); if (caps) { if (cp == 0x0451) cp = 0x0401; // ё -> Ё else if (cp == 0x03C2) cp = 0x03A3; // ς -> Σ else if (cp == 0x00FF) cp = 0x0178; // ÿ -> Ÿ (French; breaks the à-þ flat -0x20 rule below — Ÿ sits outside Latin-1 Supplement entirely) else if (cp >= 0x0430 && cp <= 0x044F) cp -= 0x20; // а-я -> А-Я else if (cp >= 0x03B1 && cp <= 0x03C9) cp -= 0x20; // α-ω -> Α-Ω else if (cp >= 0x00E0 && cp <= 0x00FE && cp != 0x00F7) cp -= 0x20; // à-þ -> À-Þ else if (cp >= 0x0100 && cp < 0x0180 && (cp & 1) == 1) cp -= 1; // ą-ż (odd=lower) -> Ą-Ż else if (cp >= 'a' && cp <= 'z') cp -= 0x20; // a-z -> A-Z } if (cp < 0x80) { out[o++] = (char)cp; } else { out[o++] = (char)(0xC0 | (cp >> 6)); out[o++] = (char)(0x80 | (cp & 0x3F)); } } out[o] = '\0'; } // Codepoint count of a UTF-8 string (byte length overcounts once a 2-byte // alphabet is involved — this is what T9 cycling needs instead of strlen()). static int kbUtf8Len(const char* s) { int n = 0; const uint8_t* p = (const uint8_t*)s; while (*p) { DisplayDriver::decodeCodepoint(p); n++; } return n; } // Extract the idx-th codepoint of a UTF-8 string as its own NUL-terminated // UTF-8 bytes in `out` (>= 5 bytes). Empty string if idx is out of range. static void kbUtf8CharAt(const char* s, int idx, char* out) { const uint8_t* p = (const uint8_t*)s; for (int i = 0; *p; i++) { const uint8_t* start = p; DisplayDriver::decodeCodepoint(p); if (i == idx) { int n = (int)(p - start); memcpy(out, start, n); out[n] = '\0'; return; } } out[0] = '\0'; } // Byte width of the LAST codepoint in buf[0..len) — for backspace and the T9 // in-place replace, which must remove/overwrite a whole codepoint, not one // byte (a lone trailing continuation byte would otherwise corrupt a 2-byte // character). Capped at 4 (UTF-8's max), though this codebase's alphabets are // all <= 2 bytes today. static int kbUtf8LastCharBytes(const char* buf, int len) { if (len <= 0) return 0; int n = 1; while (n < len && n < 4 && ((uint8_t)buf[len - n] & 0xC0) == 0x80) n++; return n; } // Byte width of the codepoint STARTING at buf[pos] (pos in [0,len)) — the // forward counterpart to kbUtf8LastCharBytes, for moving the edit cursor // right by one whole codepoint instead of one byte. static int kbUtf8CharBytesAt(const char* buf, int pos, int len) { if (pos >= len) return 0; uint8_t c = (uint8_t)buf[pos]; int n = 1; if ((c & 0xE0) == 0xC0) n = 2; else if ((c & 0xF0) == 0xE0) n = 3; else if ((c & 0xF8) == 0xF0) n = 4; if (pos + n > len) n = len - pos; // truncated/corrupt sequence: don't overrun return n; } static const int KB_PH_MAX = 20; // max placeholders in list (PopupMenu::PM_MAX_ITEMS=24 is the hard ceiling) static const int KB_PH_LEN = 30; // max placeholder string length incl. null -- sized for the longest // CLI-command candidate (AdminScreen), not just the short {x} tokens static const int KB_PH_VISIBLE = 3; // items shown at once in overlay struct KeyboardWidget; // Optional hook: if set, called right before the placeholder picker opens so // the caller can repopulate the list contextually (e.g. AdminScreen's CLI // command autocomplete, filtered by what's already typed). When set, picking // an entry also replaces the in-progress word (the text since the last // space) instead of appending it -- true completion, not insertion. Fields // that don't set this (the common case -- {loc}/{time} etc.) keep the // original static-list, append-only behaviour untouched. typedef void (*PlaceholderRefreshFn)(KeyboardWidget& kb, void* ctx); struct KeyboardWidget { char buf[KB_MAX_LEN + 1]; int len; int max_len; int cursor_pos; // insertion point into buf, in bytes; defaults to len (append) bool cursor_mode; // true while Hold-Enter has parked the grid to reposition cursor_pos int row, col; int page; // see totalPages()/pageIsAltAlphabet()/pageIsSymbols() below bool caps; // Shift is one-shot by default (like a phone keyboard: capitalises just the // next letter, then reverts) — Hold-Enter on Shift toggles caps_lock, which // keeps it on for a whole run of capitals instead. bool caps_lock = false; char _ph_buf[KB_PH_MAX][KB_PH_LEN]; int _ph_count; PopupMenu _ph_menu; PlaceholderRefreshFn _ph_refresh = nullptr; void* _ph_refresh_ctx = nullptr; const char* _ph_title = "Placeholder:"; // popup title -- overridable so e.g. AdminScreen can say "Commands:" void setPlaceholderRefresh(PlaceholderRefreshFn fn, void* ctx, const char* title = "Placeholder:") { _ph_refresh = fn; _ph_refresh_ctx = ctx; _ph_title = title; } // Live setting lookup — set once by UITask::begin(). NULL only in tests/tools // that construct a KeyboardWidget standalone, in which case isT9() defaults // to ABC and hasAltAlphabet() defaults to Latin-only. NodePrefs* prefs = nullptr; bool isT9() const { return prefs && prefs->keyboard_type == 1; } bool hasAltAlphabet() const { return prefs && prefs->keyboard_alt_alphabet != NodePrefs::KB_ALPHABET_LATIN_ONLY; } // 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; // Caps state the *first* tap of the current T9 cycle applied -- reused by every // later cycling tap on the same cell, since one-shot Shift is consumed (see // below) right after that first tap, before the user has settled on a letter. // Without this, cycling to the 2nd/3rd/... candidate would always render // lowercase regardless of Shift. bool t9_caps = false; int gridRows() const { return isT9() ? KB_T9_ROWS : KB_ROWS_CHAR; } int gridCols() const { return isT9() ? KB_T9_COLS : KB_COLS_CHAR; } // ── Page model ──────────────────────────────────────────────────────────── // Logical page order: 0 = Latin letters, [1 = the enabled alt alphabet], // last = symbols. Without an alt alphabet this is exactly the original // 2-page Latin/symbols cycle; enabling one inserts its page in the middle, // so the #@/abc key's existing cycle (case 4 below) reaches it for free. int totalPages() const { return hasAltAlphabet() ? 3 : 2; } bool pageIsAltAlphabet(int pg) const { return hasAltAlphabet() && pg == 1; } bool pageIsSymbols(int pg) const { return pg == totalPages() - 1; } // ABC-grid cell content as a NUL-terminated UTF-8 string (1 codepoint). // Latin/symbols cells come back through a small scratch buffer since // KB_CHARS stores single ASCII bytes, not strings; alt-alphabet cells are // literal string-table entries, returned directly. const char* cellStr(int r, int c) const { if (pageIsAltAlphabet(page)) { switch (prefs->keyboard_alt_alphabet) { case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_CYRILLIC_CHARS[r][c]; case NodePrefs::KB_ALPHABET_GREEK: return KB_GREEK_CHARS[r][c]; case NodePrefs::KB_ALPHABET_POLISH: return KB_POLISH_CHARS[r][c]; case NodePrefs::KB_ALPHABET_CZECH: return KB_CZECH_CHARS[r][c]; case NodePrefs::KB_ALPHABET_SLOVAK: return KB_SLOVAK_CHARS[r][c]; case NodePrefs::KB_ALPHABET_GERMAN: return KB_GERMAN_CHARS[r][c]; case NodePrefs::KB_ALPHABET_FRENCH: return KB_FRENCH_CHARS[r][c]; case NodePrefs::KB_ALPHABET_SPANISH: return KB_SPANISH_CHARS[r][c]; case NodePrefs::KB_ALPHABET_PORTUGUESE: return KB_PORTUGUESE_CHARS[r][c]; case NodePrefs::KB_ALPHABET_NORDIC: return KB_NORDIC_CHARS[r][c]; default: return "?"; } } static char single[2]; single[0] = KB_CHARS[pageIsSymbols(page) ? 1 : 0][r][c]; single[1] = '\0'; return single; } // T9 group string (UTF-8) for the given cell (0-8), page-aware like cellStr. const char* t9GroupStr(int cell) const { if (pageIsAltAlphabet(page)) { switch (prefs->keyboard_alt_alphabet) { case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_T9_GROUPS_CYRILLIC[cell]; case NodePrefs::KB_ALPHABET_GREEK: return KB_T9_GROUPS_GREEK[cell]; case NodePrefs::KB_ALPHABET_POLISH: return KB_T9_GROUPS_POLISH[cell]; case NodePrefs::KB_ALPHABET_CZECH: return KB_T9_GROUPS_CZECH[cell]; case NodePrefs::KB_ALPHABET_SLOVAK: return KB_T9_GROUPS_SLOVAK[cell]; case NodePrefs::KB_ALPHABET_GERMAN: return KB_T9_GROUPS_GERMAN[cell]; case NodePrefs::KB_ALPHABET_FRENCH: return KB_T9_GROUPS_FRENCH[cell]; case NodePrefs::KB_ALPHABET_SPANISH: return KB_T9_GROUPS_SPANISH[cell]; case NodePrefs::KB_ALPHABET_PORTUGUESE: return KB_T9_GROUPS_PORTUGUESE[cell]; case NodePrefs::KB_ALPHABET_NORDIC: return KB_T9_GROUPS_NORDIC[cell]; default: return "?"; } } return KB_T9_GROUPS[pageIsSymbols(page) ? 1 : 0][cell]; } // Compact ASCII hint for the #@/abc key when it's about to switch to the // alt-alphabet page. Deliberately ASCII (not the alphabet's own script) so // it renders correctly even when Lemon isn't the user's current font choice // — unlike the alphabet's own grid, this hint is visible from the Latin/ // symbols pages too, where render() doesn't force Lemon on (see render()). static const char* altAlphabetHint(uint8_t alt) { switch (alt) { case NodePrefs::KB_ALPHABET_CYRILLIC: return "CY"; case NodePrefs::KB_ALPHABET_GREEK: return "GR"; case NodePrefs::KB_ALPHABET_POLISH: return "PL"; case NodePrefs::KB_ALPHABET_CZECH: return "CZ"; case NodePrefs::KB_ALPHABET_SLOVAK: return "SK"; case NodePrefs::KB_ALPHABET_GERMAN: return "DE"; case NodePrefs::KB_ALPHABET_FRENCH: return "FR"; case NodePrefs::KB_ALPHABET_SPANISH: return "ES"; case NodePrefs::KB_ALPHABET_PORTUGUESE: return "PT"; case NodePrefs::KB_ALPHABET_NORDIC: return "ND"; default: return "?"; } } 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); cursor_pos = len; cursor_mode = false; row = col = 0; page = 0; caps = false; caps_lock = false; t9_cell = -1; t9_cycle = 0; _ph_menu.active = false; _ph_refresh = nullptr; // opt-in per session -- the owning screen re-sets it if it wants _ph_refresh_ctx = nullptr; // contextual autocomplete right after this begin() _ph_title = "Placeholder:"; // 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; // Single UI font (misc-fixed 5x7) covers Latin/Greek/Cyrillic — the keyboard // renders in it directly, no per-render font switching or headroom padding. 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: the view follows cursor_pos (normally == len, // i.e. the end — so this is identical to the old "always the last line" // behaviour until cursor mode moves cursor_pos elsewhere, at which point // the preview scrolls to keep the repositioned cursor in view). 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 = cursor_pos / 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 <= cursor_pos && (cursor_pos < pe || pl == prev_lines - 1)); char linebuf[KB_PREVIEW_CAP + 2]; // cpl chars + cursor '_' + NUL if (cursor_here) { // Cursor drawn as an inserted '_' between whatever text precedes and // follows it on this line -- reduces to the old "text + trailing _" // when cursor_pos == len (after_n is always 0 in that case). int before_n = cursor_pos - ps; if (before_n < 0) before_n = 0; if (before_n > cpl) before_n = cpl; int line_text_end = (len < pe) ? len : pe; int after_n = line_text_end - cursor_pos; if (after_n < 0) after_n = 0; if (before_n + after_n > cpl) after_n = cpl - before_n; snprintf(linebuf, sizeof(linebuf), "%.*s_%.*s", before_n, buf + ps, after_n, buf + ps + before_n); } 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()); // Cursor-positioning mode: LEFT/RIGHT/UP/DOWN now drive the text cursor // instead of the grid (see handleInput), so the grid would otherwise just // sit there frozen with no sign anything's different. Replace it with an // explicit hint instead. if (cursor_mode) { const int hh = lh + 2; display.setColor(DisplayDriver::LIGHT); display.fillRect(0, chars_y, display.width(), hh); display.setColor(DisplayDriver::DARK); display.drawTextCentered(display.width() / 2, chars_y + 1, "CURSOR MODE"); display.setColor(DisplayDriver::LIGHT); display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "L/R move"); display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "U/D start/end"); return 50; } // 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; // Label the cell "" so it reads like a phone keypad. The // digit is what the multi-tap cycle lands on after the letters (see // handleInput: '1'+cell). No separator space — the widest group // (Cyrillic "деёжз"/"шщъыь", 5 letters x up to 2 UTF-8 bytes) + digit // still fits with room to spare. char group_shown[12]; kbApplyCapsUtf8(t9GroupStr(cell), caps, group_shown, sizeof(group_shown)); char label[14]; snprintf(label, sizeof(label), "%c%s", (char)('1' + cell), group_shown); int cx = c * cell_w; display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); int tw = display.getTextWidth(label); display.setCursor(cx + (cell_w - tw) / 2, y); display.print(label); } } } 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_buf[3]; kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf)); if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_'; int cx = c * cell_w; display.drawSelectionRow(cx, y - 1, cell_w - 1, cell_h, sel); int tw = display.getTextWidth(ch_buf); display.setCursor(cx + (cell_w - tw) / 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 // Shows what pressing it lands on next, same "reads as the // destination" convention as the original 2-page abc<->#@ toggle, // generalized to however many pages are in the cycle right now. const char* lbl; if (i == 3) { lbl = "{}"; } else { int next = (page + 1) % totalPages(); lbl = pageIsSymbols(next) ? "#@" : pageIsAltAlphabet(next) ? altAlphabetHint(prefs->keyboard_alt_alphabet) : "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); // Contextual (refresh-hook) fields complete the in-progress word -- // the text since the last space, up to the cursor -- instead of // appending after it, so picking a match doesn't duplicate what's // already been typed. Anything after the cursor (if it's not at the // end) shifts along with the insertion, same as a normal keystroke. int base_len = cursor_pos; if (_ph_refresh) { while (base_len > 0 && buf[base_len - 1] != ' ') base_len--; } int tail_len = len - cursor_pos; if (base_len + ph_len + tail_len <= max_len) { memmove(buf + base_len + ph_len, buf + cursor_pos, tail_len); memcpy(buf + base_len, ph, ph_len); len = base_len + ph_len + tail_len; cursor_pos = base_len + ph_len; buf[len] = '\0'; } } return NONE; } // Cursor-positioning sub-mode (see the Hold-Enter block below): the grid // selection is parked while LEFT/RIGHT walk cursor_pos one codepoint at a // time and UP/DOWN jump to the very start/end -- Home/End, in effect. // Enter/Cancel just leave the mode; the actual edit (insert/backspace) // happens back in normal typing, now targeting the repositioned cursor. if (cursor_mode) { if (c == KEY_LEFT) { if (cursor_pos > 0) cursor_pos -= kbUtf8LastCharBytes(buf, cursor_pos); return NONE; } if (c == KEY_RIGHT) { if (cursor_pos < len) cursor_pos += kbUtf8CharBytesAt(buf, cursor_pos, len); return NONE; } if (c == KEY_UP) { cursor_pos = 0; return NONE; } if (c == KEY_DOWN) { cursor_pos = len; return NONE; } if (c == KEY_ENTER || c == KEY_CANCEL) { cursor_mode = false; return NONE; } return NONE; } if (c == KEY_CANCEL) return CANCELLED; const int rows = gridRows(); const int cols = gridCols(); // Hold-Enter is normally "cancel", but three places give it a more useful // meaning instead: Shift -> toggle a persistent caps-lock (a plain tap is // one-shot -- see the commit sites below); Backspace -> clear the whole // field in one action instead of holding it down; anywhere on the letter/ // symbol grid -> enter cursor-positioning mode (see above). Every other // special-row cell keeps hold-to-cancel. if (c == KEY_CONTEXT_MENU) { if (row == rows && col == 0) { // Shift caps_lock = !caps_lock; caps = caps_lock; return NONE; } if (row == rows && col == 2) { // Backspace len = 0; buf[0] = '\0'; cursor_pos = 0; t9_cell = -1; return NONE; } if (row < rows) { // any letter/symbol cell cursor_mode = true; t9_cell = -1; return NONE; } return CANCELLED; } 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 = t9GroupStr(cell); int glen = kbUtf8Len(group); // codepoint count, not byte length 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 (cursor_pos > 0) { char one[5]; if (t9_cycle < glen) kbUtf8CharAt(group, t9_cycle, one); else { one[0] = (char)('1' + cell); one[1] = '\0'; } char shown[5]; kbApplyCapsUtf8(one, t9_caps, shown, sizeof(shown)); // Replace the codepoint just before the cursor (what the previous // tap inserted), preserving anything after the cursor too. int old_n = kbUtf8LastCharBytes(buf, cursor_pos); int new_pos = cursor_pos - old_n; int tail_len = len - cursor_pos; int n = (int)strlen(shown); if (new_pos + n + tail_len <= max_len) { memmove(buf + new_pos + n, buf + cursor_pos, tail_len); memcpy(buf + new_pos, shown, n); len = new_pos + n + tail_len; cursor_pos = new_pos + n; buf[len] = '\0'; } } } else if (len < max_len) { char one[5]; kbUtf8CharAt(group, 0, one); char shown[5]; kbApplyCapsUtf8(one, caps, shown, sizeof(shown)); int n = (int)strlen(shown); int tail_len = len - cursor_pos; if (len + n <= max_len) { memmove(buf + cursor_pos + n, buf + cursor_pos, tail_len); memcpy(buf + cursor_pos, shown, n); len += n; cursor_pos += n; buf[len] = '\0'; t9_cell = cell; t9_cycle = 0; t9_caps = caps; // remember it for every later cycling tap on this cell if (caps && !caps_lock) caps = false; // one-shot: only this first tap gets capitalised } } t9_last_ms = millis(); } else if (row < rows) { char shown[3]; kbApplyCapsUtf8(cellStr(row, col), caps, shown, sizeof(shown)); int n = (int)strlen(shown); int tail_len = len - cursor_pos; if (len + n <= max_len) { memmove(buf + cursor_pos + n, buf + cursor_pos, tail_len); memcpy(buf + cursor_pos, shown, n); len += n; cursor_pos += n; buf[len] = '\0'; if (caps && !caps_lock) caps = false; // one-shot: revert after the letter it capitalised } } else { t9_cell = -1; // any special-row action finalizes a pending multi-tap cycle switch (col) { // Tap toggles one-shot caps on/off; while caps_lock is held (Hold-Enter // on this key, see handleInput's top), a tap cancels the lock instead. case 0: if (caps_lock) { caps = false; caps_lock = false; } else { caps = !caps; } break; case 1: if (len < max_len) { memmove(buf + cursor_pos + 1, buf + cursor_pos, len - cursor_pos); buf[cursor_pos] = ' '; len++; cursor_pos++; buf[len] = '\0'; } break; case 2: if (cursor_pos > 0) { int n = kbUtf8LastCharBytes(buf, cursor_pos); memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos); len -= n; cursor_pos -= n; buf[len] = '\0'; } break; case 3: if (_ph_refresh) _ph_refresh(*this, _ph_refresh_ctx); // contextual repopulate, if wired up _ph_menu.begin(_ph_title, KB_PH_VISIBLE); for (int i = 0; i < _ph_count; i++) _ph_menu.addItem(_ph_buf[i]); break; case 4: page = (page + 1) % totalPages(); // cycle letters -> [alt alphabet] -> symbols break; case 5: return DONE; } } } return NONE; } };