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