From ad4668242b80d61b5b9a6de11f808e05071b74c7 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:49:04 +0200 Subject: [PATCH] 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+ opens that letter's accent popup directly. Co-Authored-By: Claude Sonnet 5 --- .../companion_radio/ui-new/KeyboardWidget.h | 32 +++++++- examples/companion_radio/ui-new/UITask.cpp | 73 ++++++++++++++----- examples/companion_radio/ui-new/UITask.h | 6 +- release-notes.md | 2 +- 4 files changed, 88 insertions(+), 25 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index ade9cd6b..10a5e673 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -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); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index daf9d860..f18b4eb1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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+ 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+ 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+ -- 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 diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 106f3d0d..73606aef 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -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(); diff --git a/release-notes.md b/release-notes.md index 57f2eb33..95f4dfb5 100644 --- a/release-notes.md +++ b/release-notes.md @@ -6,7 +6,7 @@ - **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`). - **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, Enter and Esc work as expected everywhere else too (including inside the placeholder and accent popups); Tab opens the Hold-Enter context menu (message reply/navigate, Bot/Admin/Repeater menus, …) — the whole interface is reachable without touching a physical button. +- **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, …). ### Fixes