fix(ui): CardKB key handling - debounce, Fn modifier for submit/accents

CardKB is level-triggered (repeats the held byte every poll) and its Enter
key collided with the on-screen keyboard grid's own commit action, causing
duplicate characters and accidental message sends. Debounce polling and use
the CardKB v1.1 Fn modifier (confirmed working on real hardware) instead of
tracking navigation state: plain Enter now behaves like the physical centre
button, Fn+Enter submits, Fn+Tab opens the Hold-Enter equivalent, and
Fn+<letter> opens that letter's accent popup directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-22 17:49:04 +02:00
parent 7cae6470bf
commit ad4668242b
4 changed files with 88 additions and 25 deletions

View File

@@ -645,6 +645,27 @@ struct KeyboardWidget {
return 50;
}
// 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.
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;
accent_group = gi;
accent_sel = 0;
t9_cell = -1;
return true;
}
Result handleInput(char c) {
// placeholder overlay consumes all input
if (_ph_menu.active) {
@@ -726,9 +747,10 @@ struct KeyboardWidget {
// 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 when the grid is in this exact plain state -- no popup, no
// cursor-mode, see pollCardKB()) submits the field, instead of
// KEY_ENTER's usual "commit grid cell (row,col)".
// 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.
if (c == KEY_KB_ENTER) return DONE;
if (c == 0x08) {
if (cursor_pos > 0) {
@@ -822,6 +844,10 @@ 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.
if (row < rows && isT9()) {
int cell = row * cols + col;
const char* group = t9GroupStr(cell);

View File

@@ -2070,42 +2070,75 @@ bool UITask::dequeueKey(char& c) {
return true;
}
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
// CardKB's "fn" column (key_map in M5Stack's unit_CardKB.cpp): Fn+<physical
// key> sends 0x80 + that key's row index, entirely disjoint from every other
// code this UI recognises. Indexed by (raw - 0x80); non-letter slots (digits,
// arrows, enter, tab, bs, space -- handled separately or unused) are 0.
static const char CARDKB_FN_BASE[48] = {
0,0,0,0,0,0,0,0,0,0,0,0,0, // esc,1-0,bs,tab
'q','w','e','r','t','y','u','i','o','p', 0, 0,0, // q-p, (unused), LEFT,UP
'a','s','d','f','g','h','j','k','l', 0, 0,0, // a-l, enter, DOWN,RIGHT
'z','x','c','v','b','n','m', 0,0,0, // z-m, comma,period,space
};
#endif
// Poll an optional CardKB (I2C keyboard, addr 0x5F) on Wire1/Grove, feeding
// the same key queue as every physical button. Most of its output needs no
// 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. Two bytes get remapped:
// - Enter, while the on-screen keyboard's plain grid state is active (no
// placeholder/accent popup, not in cursor-mode) -- there it means "submit
// the field", not KEY_ENTER's usual "commit grid cell (row,col)", which
// would otherwise insert a stray character since direct typing never
// touches the grid. See KeyboardWidget::handleInput()'s KEY_KB_ENTER
// branch.
// - Tab (0x09, otherwise unused by any screen) -> KEY_CONTEXT_MENU, the
// "Hold-Enter" context-menu gesture used in ~30 places across the UI
// (message reply/navigate, Bot/Admin/Repeater menus, ...). CardKB has no
// press-duration concept to detect a long-press with, so Tab is the
// closest free key standing in for it -- full keyboard-only navigation
// would otherwise be unable to reach any of those menus at all.
// 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.
// - 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
// once), so _cardkb_last_raw debounces it into one press per physical
// keypress, same as a MomentaryButton's CLICK event.
void UITask::pollCardKB() {
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
if (!_has_cardkb) return;
if (millis() - _cardkb_poll_ms < 30) return; // ~33 Hz poll, plenty for typing
_cardkb_poll_ms = millis();
// No artificial throttle: unlike a MomentaryButton (BUTTON_USE_INTERRUPTS
// latches every edge in an ISR ring buffer, so it survives a blocking e-ink
// refresh untouched), CardKB is plain I2C polling with no interrupt line on
// the Grove cable and no onboard queue -- it only ever reports "what's held
// right now". A press that starts and fully releases while curr->render()
// is blocked is physically unobservable, no software fix can recover it.
// Polling every loop() iteration (same as a digital button's check(), which
// has no throttle either) just shrinks that miss window down to exactly the
// render() duration instead of render()+30ms.
Wire1.requestFrom(0x5F, 1);
if (!Wire1.available()) return;
uint8_t raw = Wire1.read();
if (raw == 0) return; // no key down
if (raw == _cardkb_last_raw) return; // still held (or still released) -- no new edge
_cardkb_last_raw = raw;
if (raw == 0) return; // key just released, nothing to enqueue
char key;
if (raw == 0x0D &&
_kb.isVisible() && !_kb._ph_menu.active && !_kb.cursor_mode && !_kb.accent_active) {
if (raw == 0xA3) { // Fn+Enter -- submit the field
key = KEY_KB_ENTER;
} else if (raw == 0x09) {
} 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;
key = KEY_CONTEXT_MENU;
} else if (raw >= 0x80 && raw <= 0xAF) { // Fn+<letter> -- open its accent popup
char base = CARDKB_FN_BASE[raw - 0x80];
if (base == 0) return; // Fn+digit/symbol/arrow -- not used by this UI
char woke = checkDisplayOn(base);
if (woke) _kb.openAccentFor(base);
return;
} else {
key = (char)raw;
key = (char)raw; // plain Enter/arrows/backspace/ASCII -- byte-identical
// to a physical keystroke, no CardKB-specific state
}
enqueueKey(checkDisplayOn(key));
#endif

View File

@@ -200,7 +200,11 @@ class UITask : public AbstractUITask {
// without that bus, or when nothing ACKs 0x5F at boot.
#if defined(ENV_PIN_SDA) && defined(ENV_PIN_SCL)
bool _has_cardkb = false;
uint32_t _cardkb_poll_ms = 0;
// CardKB is level-triggered, not edge-triggered -- it keeps returning the
// same byte for as long as the physical key is held, not just once. Track
// the last raw byte seen so a held key enqueues exactly one press instead
// of one per poll tick.
uint8_t _cardkb_last_raw = 0;
#endif
void pollCardKB();