From a0b1c80c852c1b1c4efb95664ee25ee3638a2053 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:51:51 +0200 Subject: [PATCH] feat(ui): compact on-screen keyboard toggle for external keyboards Add Settings > Keyboard "Ext. KB" row (boards with a CardKB-capable I2C bus only): switching it to Compact hides the letter grid and special-row icons in favour of a one-line status (script/page, caps) plus a Fn-shortcut reminder, since an external-keyboard typist never looks at the on-screen grid. Accent/placeholder popups still render as before. Off by default. Co-Authored-By: Claude Sonnet 5 --- examples/companion_radio/DataStore.cpp | 7 + examples/companion_radio/NodePrefs.h | 17 +- .../companion_radio/ui-new/KeyboardWidget.h | 173 ++++++++++-------- .../companion_radio/ui-new/SettingsScreen.h | 16 ++ release-notes.md | 1 + 5 files changed, 138 insertions(+), 76 deletions(-) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index c0da5f34..0c05b252 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -551,6 +551,12 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no rd(&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); if (_prefs.gpio4_mode > 3) _prefs.gpio4_mode = 0; + // → 0xC0DE0023: append the external-keyboard compact-display toggle at the + // tail. A pre-0x23 file has no byte here; clamp to 0 (full grid, unchanged + // behaviour for upgraders). + rd(&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact)); + if (_prefs.keyboard_cardkb_compact > 1) _prefs.keyboard_cardkb_compact = 0; + // Schema sentinel: bumped on layout changes. Mismatch means an older file // (or a different schema); rd() already zero-inits any fields not present, // so we just log it — next savePrefs writes the current sentinel. @@ -762,6 +768,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.gpio2_mode, sizeof(_prefs.gpio2_mode)); file.write((uint8_t *)&_prefs.gpio3_mode, sizeof(_prefs.gpio3_mode)); file.write((uint8_t *)&_prefs.gpio4_mode, sizeof(_prefs.gpio4_mode)); + file.write((uint8_t *)&_prefs.keyboard_cardkb_compact, sizeof(_prefs.keyboard_cardkb_compact)); // 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 f868d0e7..fa69eec3 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -422,6 +422,17 @@ struct NodePrefs { // persisted to file uint8_t gpio3_mode; uint8_t gpio4_mode; + // Settings > Keyboard's "Ext. KB" row (boards with a second I2C bus for an + // optional CardKB, see ENV_PIN_SDA/ENV_PIN_SCL, only). When on, the + // on-screen keyboard skips drawing its full letter grid + special-row icons + // -- an external-keyboard typist never looks at them -- and shows a compact + // one-line status (current script/page, caps) instead; the accent and + // placeholder popups still render on top exactly as before (see + // KeyboardWidget::render()). Manual toggle rather than auto-detected, so it + // stays put even if the module is briefly unplugged. Default 0 (full grid, + // unchanged behaviour) on upgrade. + uint8_t keyboard_cardkb_compact; + // 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; @@ -484,7 +495,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 = 0xC0DE0022; + static const uint32_t SCHEMA_SENTINEL = 0xC0DE0023; // 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 @@ -581,7 +592,9 @@ struct NodePrefs { // persisted to file // 3. clamp it on load (an upgrader's file lacks it → stray bytes) // 4. bump SCHEMA_SENTINEL's low byte // (Padding can also shift sizeof; a "false" trip just means re-check + rebump.) -// keyboard_main_alphabet (added in the prior bump) landed in existing tail +// keyboard_cardkb_compact (0xC0DE0023) also landed in existing tail padding -- +// confirmed via a real build -- leaving sizeof unchanged at 2720. +// keyboard_main_alphabet (added in an earlier bump) landed in existing tail // padding -- confirmed via a real build's sizeof() -- so that bump left the // size unchanged. bot_actions_dm/ch/room and gpio1..4_mode (the last two // bumps, 7 more uint8_t total) added 8 bytes, not 7 -- one byte of tail diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 10a5e673..6234eece 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -526,81 +526,106 @@ struct KeyboardWidget { 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) ? "#@" : scriptHint(scriptAt(next)); - } - 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); - } + // Compact mode (Settings > Keyboard's "Ext. KB" row): an external-keyboard + // typist never looks at the letter grid or special-row icons, so skip + // drawing them and show a one-line status (current script/page, caps) + // instead. row/col/page keep updating exactly as before even while this + // is on (arrows and Fn+letter both still work; the accent popup below + // still anchors on `row`), so nothing breaks if physical buttons get used + // meanwhile -- it just won't be visible which cell is selected. + if (prefs && prefs->keyboard_cardkb_compact) { + const int hh = lh + 2; display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, chars_y, display.width(), hh); + display.setColor(DisplayDriver::DARK); + const char* script_name = pageIsSymbols(page) ? "Symbols" + : (scriptAt(page) == NodePrefs::KB_ALPHABET_CYRILLIC) ? "Cyrillic" + : (scriptAt(page) == NodePrefs::KB_ALPHABET_GREEK) ? "Greek" + : "Latin"; + char status[32]; + if (pageIsSymbols(page)) snprintf(status, sizeof(status), "%s%s", script_name, caps ? " CAPS" : ""); + else snprintf(status, sizeof(status), "%s %s%s", script_name, isT9() ? "T9" : "ABC", caps ? " CAPS" : ""); + display.drawTextCentered(display.width() / 2, chars_y + 1, status); + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "Fn+Tab menu"); + display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "Fn+letter accent"); + } else { + // 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) ? "#@" : scriptHint(scriptAt(next)); + } + 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); + } } // Accent popup: floats over the still-visible grid (same idea as the diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index 0e63f3f7..bcac09a5 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -73,6 +73,9 @@ class SettingsScreen : public UIScreen { KEYBOARD_TYPE, KEYBOARD_MAIN_ALPHABET, KEYBOARD_ALPHABET, +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + KEYBOARD_CARDKB_COMPACT, +#endif // Contacts section SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, // Messages section @@ -574,6 +577,12 @@ class SettingsScreen : public UIScreen { display.print("Additional"); display.setCursor(valCol(display), y); display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0)); +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + } else if (item == KEYBOARD_CARDKB_COMPACT) { + display.print("Ext. KB"); + display.setCursor(valCol(display), y); + display.print((p && p->keyboard_cardkb_compact) ? "Compact" : "Full"); +#endif } else if (item == BATT_DISPLAY) { display.print("BattDisp"); display.setCursor(valCol(display), y); @@ -945,6 +954,13 @@ public: _dirty = true; return true; } +#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL) + if (_selected == KEYBOARD_CARDKB_COMPACT && p && (left || right || enter)) { + p->keyboard_cardkb_compact ^= 1; + _dirty = true; + return true; + } +#endif if (_selected == DM_RESEND && p) { int n = p->dm_resend_count; if (right || enter) n = (n + 1) % 6; // 0..5, wraps diff --git a/release-notes.md b/release-notes.md index 95f4dfb5..382e05ef 100644 --- a/release-notes.md +++ b/release-notes.md @@ -7,6 +7,7 @@ - **Bot Trigger fields accept multiple phrases.** Pack several trigger words into one Trigger field, comma-separated (`hi,hello there,yo`) — matching any one of them fires the reply, same as before for a single phrase. - **Remote Bot Actions gain `!gpio1`..`!gpio4`** (Wio Tracker L1 only — 4 otherwise-unused pins). Each pin is independently set to Off/Input/Output (GPIO1/GPIO2 also offer Analog) from a new **Tools › GPIO** screen; `!gpio1 on`/`!gpio1 off` drives an Output pin remotely, a bare `!gpio1` reports the current mode and reading (including a millivolt value in Analog mode). Gated by the same per-target Actions toggle as `!buzz`/`!gps`/`!advert`. - **Optional CardKB support (Wio Tracker L1, Grove I2C) — full keyboard-only navigation.** Plug an M5Stack CardKB into the Grove connector and it's auto-detected at boot — no setting to flip. Typing goes straight into the message/name field instead of navigating the on-screen keyboard grid; arrows and Esc work as expected everywhere else too (including inside the placeholder and accent popups). Enter acts like the physical centre button (advances the on-screen grid selection) rather than submitting, so **Fn+Enter** sends the message/confirms the field from anywhere, **Fn+``** opens that letter's accent popup directly (no arrow-hunting needed — handy for Polish/Czech/etc. diacritics), and **Fn+Tab** opens the Hold-Enter equivalent (Shift-lock, clear field) for whatever's selected; plain **Tab** still opens the Hold-Enter context menu everywhere else (message reply/navigate, Bot/Admin/Repeater menus, …). +- **Settings › Keyboard gets an "Ext. KB" row** (boards that support CardKB, above). Switching it to **Compact** hides the on-screen letter grid and special-row icons — a CardKB typist never looks at them — replacing them with a one-line status (current script/page, caps) plus a reminder of the Fn shortcuts; the accent and placeholder popups still show normally. Off (**Full**) by default. ### Fixes