fix(keyboard): joystick-free Compact mode for external keyboards

Compact mode (Settings > Keyboard's "Ext. KB" row) is meant to guarantee
operation with no joystick at all, but it was still half-tied to the
on-screen grid it hides:

- arrows now move the text cursor directly instead of a grid selection
  nobody can see, and Tab opens the placeholder picker directly instead of
  the row/col-dependent Hold-Enter dispatch
- plain Enter submits the field (there's no grid cell to have deliberately
  landed on), same as Fn+Enter
- Fn+letter's accent popup no longer gates on the grid's script/T9
  settings -- CardKB always types plain Latin regardless of them, so the
  gate only made the gesture silently stop working
- the whole status line is gone: nothing it showed (script, T9-vs-ABC,
  caps) is actionable from an external keyboard. The freed height goes to
  message-preview lines, floored at the smallest grid's footprint so
  cursor mode's own hint block still fits
- the accent popup gets a fixed slot instead of anchoring on a `row` that
  is never deliberately navigated to in this mode

Also fixes a text-corrupting invariant break: moveCursorDirect() and
openPlaceholders() move the cursor without finalizing a pending T9
multi-tap cycle, so a later tap on the same cell within the timeout
overwrote an unrelated character. Every other cursor-moving path already
cleared it.

Fn+Tab is dropped as a separate shortcut -- plain Tab already covered
every case it did. Fn+Enter no longer reads as a dead key in cursor mode.

Direct typing moves into insertTyped(), one translation point documenting
what a future relabelled-keycap layout (Cyrillic/Greek) would need.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-27 23:14:57 +02:00
parent 8e5f083e2e
commit e558ea0b86
3 changed files with 169 additions and 67 deletions

View File

@@ -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

View File

@@ -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+<letter> 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

View File

@@ -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+`<letter>`** 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+`<letter>`** 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