diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index efc98894..b0c9b699 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -296,6 +296,14 @@ struct KeyboardWidget { void beginFrame() { _visible = false; } bool isVisible() const { return _visible; } + // True while the plain letter/symbol grid is the active input surface -- + // showing, no placeholder/accent popup open, not mid cursor-reposition. + // Used by CardKB's Compact-mode handling (UITask::pollCardKB()) to tell + // "grid navigation" apart from every other state arrows/Enter already mean + // something else in (those all render their own visible feedback, so they + // don't need Compact's special-casing). + bool inPlainGridState() const { return isVisible() && !_ph_menu.active && !cursor_mode && !accent_active; } + char _ph_buf[KB_PH_MAX][KB_PH_LEN]; int _ph_count; PopupMenu _ph_menu; @@ -443,6 +451,29 @@ struct KeyboardWidget { } } + // Insert one character typed literally on an external keyboard (CardKB or + // similar), as opposed to committed off the on-screen grid. The source + // already sends the correct case, so no Shift/caps-lock is re-applied. + // + // This is the single translation point for external-keyboard input: today + // it's the identity mapping (CardKB is a Latin QWERTY, so what it sends is + // what gets typed, regardless of the on-screen grid's script/T9 settings -- + // those only govern grid navigation). To support relabelled keycaps + // (Cyrillic/Greek/...) later, map `c` to that layout's codepoint here and + // hand the resulting UTF-8 to insertGlyph() -- everything downstream already + // works in codepoints, not bytes. Such a layout belongs on its own setting, + // not on keyboard_main_alphabet: it describes the physical keycaps, which + // are independent of what the on-screen grid shows. Digits/punctuation + // should keep passing through unmapped, and Fn+letter accents + // (openAccentFor()) stay Latin-only -- they're meaningless under non-Latin + // keycaps. + void insertTyped(char c) { + t9_cell = -1; // otherwise a same-cell T9 tap within KB_T9_TIMEOUT_MS would + // overwrite this character instead of inserting a new one + char one[2] = { c, '\0' }; + insertGlyph(one, false); + } + void clearPlaceholders() { _ph_count = 0; } void addPlaceholder(const char* ph) { @@ -453,6 +484,36 @@ struct KeyboardWidget { } } + // Opens the placeholder picker directly -- same as navigating the grid to + // the special row's {} cell and pressing Enter (see handleInput's KEY_ENTER + // special-row case 3, which now calls this too). Used by CardKB's Compact + // mode (UITask::pollCardKB(), plain Tab) so a placeholder is reachable + // without ever seeing or navigating the grid. + void openPlaceholders() { + t9_cell = -1; // finalize any pending multi-tap cycle -- the pick below moves the + // cursor, so a later same-cell tap must not "continue" onto it + 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]); + } + + // Moves the text cursor directly (LEFT/RIGHT one codepoint, UP/DOWN to + // start/end) without engaging cursor_mode's grid-boundary wrap (continuing + // into the special row / row 0 once already at an end) -- that wrap exists + // so a physical-button user can see where they land back on the grid, which + // doesn't apply here: CardKB's Compact mode never navigates the grid at + // all, so there's no grid position to wrap into. See pollCardKB(). + void moveCursorDirect(char key) { + t9_cell = -1; // same invariant every other cursor-moving path keeps: a pending + // multi-tap cycle must not resume against a moved cursor and + // overwrite an unrelated character. (cursor_mode gets this for + // free -- its KEY_UP entry point already clears t9_cell.) + if (key == KEY_LEFT) { if (cursor_pos > 0) cursor_pos -= kbUtf8LastCharBytes(buf, cursor_pos); } + else if (key == KEY_RIGHT) { if (cursor_pos < len) cursor_pos += kbUtf8CharBytesAt(buf, cursor_pos, len); } + else if (key == KEY_UP) { cursor_pos = 0; } + else if (key == KEY_DOWN) { cursor_pos = len; } + } + int render(DisplayDriver& display) { _visible = true; // A stale mid-cycle T9 press (no further input since) finalizes on its own — @@ -470,8 +531,15 @@ struct KeyboardWidget { 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; + bool compact_ui = prefs && prefs->keyboard_cardkb_compact; + // compact: don't stretch cells beyond lh; freed vertical space goes to preview lines. + // Compact mode only ever draws 2 short hint lines (no grid, no status line + // -- see below), but reserves at least as much height as the smallest real + // grid (T9's 3 rows) would need, rather than shrinking to just those 2 + // lines: cursor_mode's own 3-line hint (drawn in this same region, + // regardless of Compact, for a physical-button user) already relies on + // that floor and would otherwise get clipped. + const int kb_h = compact_ui ? (KB_T9_ROWS + 1) * lh : (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; @@ -535,27 +603,21 @@ struct KeyboardWidget { // 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; + // drawing them entirely -- no status line either, since nothing it could + // show (script/page, T9-vs-ABC, caps) is actually actionable from CardKB: + // typing is always plain Latin ASCII regardless of Main/Additional + // alphabet or keyboard_type (direct-typing passthrough, see + // UITask::pollCardKB()), Fn+letter's accent popup now works the same way + // regardless of them too (see openAccentFor()), and caps-lock has no + // CardKB gesture to toggle it at all. Just the two shortcuts that still do + // something here (arrows/Enter are self-explanatory -- cursor movement and + // submit -- so they get no hint of their own). Physical buttons (if used + // instead of/alongside CardKB) still drive row/col/page as normal; it + // just won't be visible on this screen which cell is selected. + if (compact_ui) { 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"); + display.drawTextCentered(display.width() / 2, chars_y, "Tab: placeholders"); + display.drawTextCentered(display.width() / 2, chars_y + lh, "Fn+letter: accent"); } else { // character grid if (isT9()) { @@ -639,6 +701,10 @@ struct KeyboardWidget { // placeholder overlay just below), anchored on the held letter's own row // so it reads as "popping out of" that key instead of taking over the // whole keyboard. LEFT/RIGHT picks, Enter commits, Cancel dismisses. + // In Compact there's no grid drawn and `row` was never deliberately + // navigated to, so anchoring on it would just park the popup at an + // arbitrary height (and possibly over a hint line) -- it gets a fixed slot + // under the two hints instead. if (accent_active) { const char* group = KB_ACCENT_VARIANTS[accent_group]; int n = kbUtf8Len(group); @@ -648,8 +714,8 @@ struct KeyboardWidget { int max_bw = display.width() - 4; if (bw > max_bw) { bw = max_bw; seg_w = bw / n; } int bx = (display.width() - bw) / 2; - int by = chars_y + row * cell_h - 1; - int bh = cell_h + 1; + int by = compact_ui ? (chars_y + 2 * lh + 2) : (chars_y + row * cell_h - 1); + int bh = compact_ui ? (lh + 2) : (cell_h + 1); display.setColor(DisplayDriver::DARK); display.fillRect(bx, by, bw, bh); display.setColor(DisplayDriver::LIGHT); @@ -680,15 +746,18 @@ struct KeyboardWidget { // CardKB's Fn+letter ("alt") gesture, see UITask::pollCardKB(): open the // accent popup for this base Latin letter directly, skipping the // arrow-hunt to find its cell first. `base` always comes from CardKB's own - // physical QWERTY layout, so this only makes sense -- same gating as the - // physical Hold-Enter-on-a-letter-cell path just below -- while the - // keyboard is idle on its plain Latin page: on T9, symbols, or a non-Latin - // main/alt script (Cyrillic, Greek, ...), the on-screen grid isn't showing - // Latin letters at all, so it's a no-op rather than inserting a stray - // Latin accent into unrelated text. + // physical QWERTY layout -- CardKB is a Latin keyboard, so it always types + // plain ASCII regardless of the on-screen grid's current page/script/T9 + // setting (those only govern what the *grid* shows for physical-button + // navigation, a completely separate input path with its own copy of this + // same gate at the Hold-Enter-on-a-letter-cell site in handleInput()). + // Gating this one on the grid's page/script/T9 state would make Fn+letter + // silently stop working whenever Main alphabet is set to Cyrillic/Greek or + // keyboard_type to T9, even though CardKB is still typing plain Latin text + // just fine -- so this only checks that no other exclusive input mode + // (popup/cursor-move) is already in progress, same as inPlainGridState(). bool openAccentFor(char base) { if (!isVisible() || _ph_menu.active || cursor_mode || accent_active) return false; - if (isT9() || pageIsSymbols(page) || scriptAt(page) != NodePrefs::KB_ALPHABET_LATIN_ONLY) return false; int gi = findAccentGroup(base); if (gi < 0) return false; accent_active = true; @@ -752,7 +821,11 @@ struct KeyboardWidget { row = 0; return NONE; } - if (c == KEY_ENTER || c == KEY_CANCEL) { cursor_mode = false; return NONE; } + // KEY_KB_ENTER (external keyboard's Fn+Enter) leaves the mode too rather + // than being silently eaten -- it's the one key an external-keyboard + // typist would reach for here, and cursor mode is only ever entered from + // a physical button, so without this it looks like a dead key. + if (c == KEY_ENTER || c == KEY_CANCEL || c == KEY_KB_ENTER) { cursor_mode = false; return NONE; } return NONE; } @@ -778,11 +851,13 @@ struct KeyboardWidget { // source, see UITask::pollCardKB()). Printable characters insert // straight at the cursor, bypassing the on-screen grid entirely -- no // caps re-application, the source already sends the correct case. - // Backspace deletes the previous character. KEY_KB_ENTER (only ever - // emitted for CardKB's Fn+Enter, see pollCardKB()) submits the field - // directly -- plain Enter (byte-identical to a physical button's - // KEY_ENTER) always acts on the grid instead, same as a physical-button - // user, so there's no ambiguity to resolve here. + // Backspace deletes the previous character. KEY_KB_ENTER submits the + // field directly -- pollCardKB() emits it for Fn+Enter always, and for + // plain Enter too when Compact mode's plain grid state applies (there's + // no grid cell to commit there). Every other plain Enter (Full mode, or + // Compact but mid popup/cursor-move) arrives here as ordinary KEY_ENTER + // and falls through to the grid dispatch below instead, so there's still + // no ambiguity to resolve at this layer -- pollCardKB() already decided. if (c == KEY_KB_ENTER) return DONE; if (c == 0x08) { t9_cell = -1; // invalidate any pending T9 cycle -- see the grid paths below @@ -795,10 +870,7 @@ struct KeyboardWidget { return NONE; } if (c >= 0x20 && c <= 0x7E) { - t9_cell = -1; // otherwise a same-cell T9 tap within KB_T9_TIMEOUT_MS would - // overwrite this character instead of inserting a new one - char one[2] = { c, '\0' }; - insertGlyph(one, false); + insertTyped(c); return NONE; } @@ -879,10 +951,11 @@ struct KeyboardWidget { return NONE; } if (c == KEY_ENTER) { - // Plain Enter always acts on the grid (repeat a letter, cycle T9, - // switch page/script, reach the special row's DONE cell) -- same as a - // physical button. CardKB submits via the separate KEY_KB_ENTER - // (Fn+Enter, see pollCardKB()) instead, so there's no ambiguity here. + // By the time a plain KEY_ENTER reaches here, it's a real grid + // interaction -- repeat a letter, cycle T9, switch page/script, reach + // the special row's DONE cell -- same as a physical button. CardKB's + // own Enter is only ever KEY_ENTER when that's true (see pollCardKB()'s + // KEY_KB_ENTER cases just above), so there's no ambiguity here. if (row < rows && isT9()) { int cell = row * cols + col; const char* group = t9GroupStr(cell); @@ -955,9 +1028,7 @@ struct KeyboardWidget { } 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]); + openPlaceholders(); break; case 4: page = (page + 1) % totalPages(); // cycle letters -> [alt alphabet] -> symbols diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 85e8b6d0..532b5047 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2109,16 +2109,23 @@ static const char CARDKB_FN_BASE[48] = { // translation at all: CardKB's own arrow/Enter/Esc byte codes are already // identical to this UI's KEY_LEFT/UP/DOWN/RIGHT/ENTER/CANCEL (0xB4-0xB7, 13, // 27), and Backspace (0x08) / printable ASCII (0x20-0x7E) collide with -// nothing that existed before. Plain Enter now always acts like the physical -// centre button (commit/repeat a grid cell) -- no more ambiguity to resolve, -// since Fn gives two clean, stateless modifiers instead: -// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) from anywhere, without -// needing to navigate to the special row's DONE cell. -// - Fn+Tab (0x8C) opens the Hold-Enter equivalent (shift-lock, clear-all, -// accent popup on whatever cell is selected) -- plain Tab (otherwise -// unused) still does this for the ~30 non-keyboard Hold-Enter menus -// (message reply/navigate, Bot/Admin/Repeater, ...) but is inert while the -// on-screen keyboard itself is showing, where Fn+Tab/Fn+letter cover it. +// nothing that existed before. Plain Enter/arrows act like the physical +// centre button/joystick (grid commit/navigate) -- except in Compact mode's +// plain grid state (see below), which is designed to need no joystick at all. +// Tab (0x09, otherwise unused) is the Hold-Enter equivalent everywhere, +// including the ~30 non-keyboard Hold-Enter menus (message reply/navigate, +// Bot/Admin/Repeater, ...) and inside the on-screen keyboard itself (shift- +// lock, clear-all, accent popup on whatever cell is selected) -- it used to +// need a separate Fn+Tab for the latter, but that was pure redundancy: plain +// Tab already covered every case Fn+Tab did, just not while the keyboard was +// showing, so the carve-out was dropped instead of the shortcut. In Compact +// mode's plain grid state Tab means something more useful instead (opens the +// placeholder picker directly -- see below). Fn still gives two other clean, +// stateless modifiers: +// - Fn+Enter (0xA3) submits the field (KEY_KB_ENTER) without needing to +// navigate to the special row's DONE cell. The placeholder/accent popups +// are modal and consume it first (dismiss them with Enter/Esc), same as +// they consume every other key. // - Fn+ opens the accent popup for that base letter directly // (KeyboardWidget::openAccentFor()) -- no arrow-hunting across the grid. // CardKB is level-triggered (it keeps returning the held key's byte, not just @@ -2143,13 +2150,34 @@ void UITask::pollCardKB() { _cardkb_last_raw = raw; if (raw == 0) return; // key just released, nothing to enqueue + // Compact mode (Settings > Keyboard's "Ext. KB" row) hides the letter grid + // entirely, and is meant to guarantee joystick-free operation: while it's + // the active surface (KeyboardWidget::inPlainGridState() -- showing, no + // popup open, not already mid cursor-move) arrows drive the text cursor + // directly instead of a grid selection nobody could see anyway, and plain + // Tab opens the placeholder picker directly instead of the row/col-dependent + // Hold-Enter dispatch (which would be meaningless here -- row/col are never + // deliberately navigated to in this mode). Cursor mode / the accent / + // placeholder popups all render their own visible feedback regardless of + // Compact, so none of this applies once inPlainGridState() is false -- + // arrows/Tab fall through to their normal meaning there (e.g. arrows drive + // the placeholder/accent popup's own selection). + bool compact_grid = _node_prefs && _node_prefs->keyboard_cardkb_compact && _kb.inPlainGridState(); + char key; if (raw == 0xA3) { // Fn+Enter -- submit the field key = KEY_KB_ENTER; - } else if (raw == 0x8C) { // Fn+Tab -- Hold-Enter equivalent, unconditionally - key = KEY_CONTEXT_MENU; - } else if (raw == 0x09) { // plain Tab -- same, but only outside the keyboard - if (_kb.isVisible()) return; + } else if (compact_grid && (raw == (uint8_t)KEY_LEFT || raw == (uint8_t)KEY_UP || + raw == (uint8_t)KEY_DOWN || raw == (uint8_t)KEY_RIGHT)) { + char woke = checkDisplayOn((char)raw); // already sets _next_refresh=0 when the display was on + if (woke && !_locked) _kb.moveCursorDirect((char)raw); + return; + } else if (raw == 0x09) { // Tab -- Hold-Enter equivalent, always (single shortcut: there used to + if (compact_grid) { // also be a separate Fn+Tab for this, but plain Tab already covers every + char woke = checkDisplayOn((char)raw); // case Fn+Tab did -- outside the keyboard, and now inside it + if (woke && !_locked) _kb.openPlaceholders(); // too -- so the modifier was pure redundancy) + return; + } key = KEY_CONTEXT_MENU; } else if (raw == 0x80) { // Fn+Esc -- CardKB's lock/unlock gesture: a single press toggles _locked @@ -2186,8 +2214,11 @@ void UITask::pollCardKB() { if (woke && !_locked) _kb.openAccentFor(base); return; } else { - key = (char)raw; // plain Enter/arrows/backspace/ASCII -- byte-identical - // to a physical keystroke, no CardKB-specific state + // Plain Enter would otherwise commit whatever grid cell row/col happen to + // be frozen at (there's no grid navigation to have deliberately landed on + // one in Compact) -- submit instead, same as Fn+Enter. Backspace/ASCII + // passthrough is unaffected by Compact either way. + key = (compact_grid && raw == (uint8_t)KEY_ENTER) ? KEY_KB_ENTER : (char)raw; } enqueueKey(checkDisplayOn(key)); #endif diff --git a/release-notes.md b/release-notes.md index b1b2b1c7..aaec771b 100644 --- a/release-notes.md +++ b/release-notes.md @@ -3,12 +3,12 @@ ### What's new - **Auto-Reply Bot is renamed to Remote Bot.** It's grown beyond auto-replies into remote device control (Actions, below), so the Tools screen entry and docs now say "Remote Bot" — same screen, same settings, nothing to reconfigure. -- **Remote Bot gains Actions: `!buzz`, `!gps`, `!advert`.** A new **Actions** toggle (per target — Direct/Channel/Room, nested under each target's existing **Commands** toggle) enables three commands that change the device's own behaviour instead of just reporting on it: `!buzz [seconds]` sounds the buzzer as a find-me signal (default 5s, capped at 30s, sounds even if the buzzer is muted), `!gps on`/`!gps off` toggles GPS, and `!advert` sends an immediate advert. Off by default; combines with the existing query commands in one message the same way (`!batt !gps on` → `4.10V | GPS: on`). +- **Remote Bot gains Actions: `!buzz`, `!gps`, `!advert`.** A new **Actions** toggle (per target — Direct/Channel/Room, nested under each target's existing **Commands** toggle) enables three commands that change the device's own behaviour instead of just reporting on it: `!buzz [seconds]` sounds the buzzer as a find-me signal (default 5s, capped at 30s, sounds even if the buzzer is muted), `!gps on`/`!gps off` toggles GPS, and `!advert` sends an immediate advert (0 hop). Off by default; combines with the existing query commands in one message the same way (`!batt !gps on` → `4.10V | GPS: on`). - **`!gps fix [seconds]` — single-shot GPS location, no need to leave GPS running.** Turns GPS on (if it wasn't already), waits for a stabilised fix (HDOP ≤ 2.0 — falls back to ≥8 satellites on GPS hardware that doesn't report HDOP — then averages for 10s), sends the position, and restores GPS to whatever state it was in before — up to a 90s timeout by default (override with an optional argument, clamped to 15-300s, for a poor sky view where 90s isn't always enough), after which it reports a partial fix (if it got at least some samples) or failure. Replies in two parts: an immediate "acquiring fix..." ack, then the actual position as a follow-up message once ready. Only one fix request can be in flight at a time (a second one gets "fix already pending"); same Actions toggle as `!buzz`/`!gps`/`!advert`. - **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. +- **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, always as plain Latin text regardless of the Settings › Keyboard language/type — 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, and **Fn+``** opens that letter's accent popup directly (no arrow-hunting needed — handy for Polish/Czech/etc. diacritics — and works no matter what language/keyboard type is configured, since CardKB always types Latin either way). **Tab** is the Hold-Enter equivalent everywhere, including message reply/navigate and the Bot/Admin/Repeater menus — same single shortcut whether or not the on-screen keyboard is showing. +- **Settings › Keyboard gets an "Ext. KB" row** (boards that support CardKB, above). Switching it to **Compact** hides the on-screen letter grid, special-row icons, and status line entirely — a CardKB typist never looks at any of it, and none of it (script/page, T9-vs-ABC, caps) is actually actionable from CardKB anyway — replacing them with just a reminder of the Tab/Fn+letter shortcuts; the accent and placeholder popups still show normally. With the grid hidden, arrows and Tab take on more useful direct meanings instead of driving an invisible grid selection: **arrows** move the text cursor immediately (no more entering cursor mode first), **plain Enter** submits the message/field (same as Fn+Enter — there's no grid cell to commit), and **plain Tab** opens the placeholder picker directly. Compact is designed to need no joystick/physical button at all, and reclaims the freed space for extra message-preview lines. Off (**Full**) by default. ### Fixes