From 5c029e752608acd6a4b876f129f893f60e2ce843 Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Mon, 24 Aug 2026 15:09:53 -0400 Subject: [PATCH] Latch keyboard symbol modifiers Signed-off-by: Michael A. Cojocari --- DEVICES.md | 6 + TLORA_PAGER_PORT.md | 12 +- TLORA_PAGER_SHORTCUTS.md | 37 +++--- src/helpers/input/LatchedModifier.h | 91 ++++++++++++++ src/helpers/input/PagerKeyboard.cpp | 141 ++++++--------------- src/helpers/input/PagerKeyboard.h | 17 ++- src/helpers/input/PagerKeyboardState.h | 113 +++++++++++++++++ src/helpers/input/TDeckKeyboard.cpp | 147 ++++++++++++++++++++-- src/helpers/input/TDeckKeyboard.h | 18 ++- src/helpers/input/TDeckKeyboardState.h | 111 +++++++++++++++++ src/ui-touch/UITask.cpp | 61 +++------ test/test_latched_modifier.cpp | 62 ++++++++++ test/test_pager_keyboard_state.cpp | 87 +++++++++++++ test/test_tdeck_keyboard_state.cpp | 164 +++++++++++++++++++++++++ 14 files changed, 866 insertions(+), 201 deletions(-) create mode 100644 src/helpers/input/LatchedModifier.h create mode 100644 src/helpers/input/PagerKeyboardState.h create mode 100644 src/helpers/input/TDeckKeyboardState.h create mode 100644 test/test_latched_modifier.cpp create mode 100644 test/test_pager_keyboard_state.cpp create mode 100644 test/test_tdeck_keyboard_state.cpp diff --git a/DEVICES.md b/DEVICES.md index a863ef68..48205038 100644 --- a/DEVICES.md +++ b/DEVICES.md @@ -23,6 +23,10 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases - **T-Deck**: the everything device: touch, physical keyboard, trackball cursor or d-pad navigation, microSD (deep 5000-message chat history, map tile packs, data storage), GPS on the Plus, notification sounds through the I2S speaker. + Tap Sym or Alt for one symbol, or double-tap either to lock the symbol layer; + this needs [LilyGO keyboard-controller firmware with raw matrix mode](https://github.com/Xinyuan-LilyGO/T-Deck/tree/master/examples/Keyboard_ESP32C3) + (June 2025 or newer). Older controller firmware keeps normal typing and + reports the unavailable latch mode on Serial. - **Heltec V4 + TFT**: touch UI with the on-screen keyboard; the optional Expansion Kit adds environment sensors (home-screen chart) and a piezo buzzer. V4.3 boards get the switchable high-gain receive LNA toggle. @@ -33,6 +37,8 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases - **T-Lora Pager**: no touchscreen at all — the QWERTY keyboard and the rotary encoder drive everything (Alt+turn free-scrolls a page; see [TLORA_PAGER_SHORTCUTS.md](TLORA_PAGER_SHORTCUTS.md) for the full key map). + Tap Fn/Alt for one symbol or double-tap it to lock the symbol layer; physical + hold combinations keep their existing behavior. GPS, microSD, keyboard backlight, lock screen and notification sound through the onboard codec and amp all work. Two builds, one per radio: LR1121 and SX1262 — flashing the wrong one leaves you with no radio, so check the label diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md index 08c1d8e7..89916f29 100644 --- a/TLORA_PAGER_PORT.md +++ b/TLORA_PAGER_PORT.md @@ -225,8 +225,8 @@ WASD-panning). `updatePagerAltShiftChord()` toggles Caps Lock ONLY while a text field is actually being edited (same "ta" derivation `handleHwKey()`'s TLORA_PAGER branch already uses to tell a bound-but-unfocused field apart from one - actually being edited), and jumps straight to Home (`navGoToMainTab`) - everywhere else. + actually being edited), and is a no-op everywhere else. Alt+Backspace is + the context-independent Home shortcut. 3. **@-mention contact picker made encoder-navigable** (commit `57802eb`) — the same unreachable-without-touch problem the accent box had, fixed the same way (`mentionNavRestyle()`/`mentionNavConfirm()` mirror @@ -622,9 +622,9 @@ existing boards. Notes: `pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s `updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text field is actually being edited (same "ta" derivation as `handleHwKey()`'s - TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere - else — toggling Caps Lock with no field to see it change in was reported as - surprising/purposeless outside of typing. + TLORA_PAGER branch), and is a no-op everywhere else — toggling Caps Lock + with no field to see it change in was reported as surprising/purposeless + outside of typing. Alt+Backspace owns the Home shortcut. See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the @@ -751,7 +751,7 @@ existing boards. Notes: | Accent-variant popup keyboard nav | Fn+Space arms it; encoder walks variants; encoder click/Enter confirms; Backspace cancels (`s_accentnav_active`/`accentNavRestyle()`/`accentNavConfirm()`) | medium | done | | @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done | | Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done | -| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done | +| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; no-op everywhere else. Fn+Backspace jumps Home — `PagerKeyboard.cpp` only reports the chords, `UITask.cpp` decides the effects | small | done | | Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done | | App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done | | SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) | diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md index 0b4abe3a..3519edfc 100644 --- a/TLORA_PAGER_SHORTCUTS.md +++ b/TLORA_PAGER_SHORTCUTS.md @@ -40,7 +40,7 @@ while editing a text field, where the keys type normally. | Gesture | Action | Keyboard equivalent | |---|---|---| -| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) | +| Turn | Move focus to the next/previous item on screen | none — encoder only | | Short click | Select / confirm the focused item | **Enter** | | Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** | | **Fn (Alt) + turn**, on a main tab | Move between the 5 main tabs (Mail / Contacts / Home / Map / Settings) | **M / C / H / A / S** jumps directly | @@ -93,29 +93,19 @@ text box → Send**, in that order, in both directions — nothing is skipped. Turning past the △ chip (toward the messages) always jumps straight to the newest message, regardless of channel or DM. -### Backspace and Fn (Alt) — leaving vs. catching up +### Backspace — catching up -These two keys are your shortcuts in and out of "reading mode" on this -screen, and they do different things depending on whether you've got unread -messages: +**Backspace (tap)** jumps to whichever message needs your attention: -- **Fn (Alt) tapped alone** — jumps straight to the **newest** message and - moves focus into the text box, ready to type a reply. This is the - deliberate "I'm done reading, let me respond" gesture — it works regardless - of where your focus currently is in the chat. -- **Backspace (tap)** — jumps to whichever message needs your attention: - - If there are unread messages, it jumps to the first one — right below the - **"NEW ----"** divider — and selects it, so you can then turn forward - through your unread messages in order, oldest-of-the-unread first. - - If nothing is unread, it jumps to the newest message instead (same - destination as Fn+Alt, but leaves focus on the message itself rather than - the text box). +- If there are unread messages, it jumps to the first one — right below the + **"NEW ----"** divider — and selects it, so you can then turn forward + through your unread messages in order, oldest-of-the-unread first. +- If nothing is unread, it jumps to the newest message instead (same + destination, with focus left on the message). Backspace's override above only applies while you're inside an open chat and *not* actively editing the text box — the usual rule from the top of this guide; with a field focused, Backspace deletes a character as normal. -Fn (Alt) tap's override fires the same way whether or not the text box -already has focus, since landing there is the whole point of the gesture. ## Keyboard layout @@ -128,7 +118,9 @@ a s d f g h j k l [Enter] [Space] ``` -Hold **Fn (Alt)** for numbers/symbols instead: +Hold **Fn (Alt)** for numbers/symbols, or tap it once to apply this layer to +the next key only. Double-tap Fn to lock the symbol layer; tap it again to +return to letters. ``` 1 2 3 4 5 6 7 8 9 0 @@ -162,7 +154,7 @@ Hold **Fn (Alt)** for numbers/symbols instead: | **Space** (tap) | Types a space | — | | **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — | | **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) | -| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder. In an open chat: jumps to the newest message and focuses the text box instead — see [Chat screen](#chat-screen) | +| **Fn (Alt)** (tap / double-tap) | Next key uses symbols / lock symbols until tapped again | Same | The **BOOT** button (top of the device) instantly wakes the screen from idle-dim. It does *not* unlock a screen you've manually locked with the @@ -249,13 +241,14 @@ that changes keyboard language. | Input | Action | |---|---| | M / C / H / A / S (top-level main screens only) | Open Mail / Contacts / Home / Map / Settings | -| Turn encoder (or Fn tap = NEXT only) | Move focus | +| Turn encoder | Move focus | | Turn, at the loaded edge of a chat | Load more history, keep moving — only exits the list at the true oldest/newest message | | Click encoder (or Enter) | Select / confirm | | Hold encoder ~1s (or hold Backspace ~1s) | Back | | Fn + turn (main tab) | Switch tabs | | Fn + turn (page/chat) | Scroll | -| Fn tap alone | Next field (in a chat: jump to newest message + focus the text box) | +| Fn tap alone | Use the symbol layer for the next key | +| Fn double-tap | Lock the symbol layer; tap Fn again to unlock | | Hold Shift + letter | Momentary uppercase | | Fn + Shift (editing a field) | Toggle Caps Lock | | Fn + Shift (not editing a field) | Nothing | diff --git a/src/helpers/input/LatchedModifier.h b/src/helpers/input/LatchedModifier.h new file mode 100644 index 00000000..4a30c8af --- /dev/null +++ b/src/helpers/input/LatchedModifier.h @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +class LatchedModifier { + public: + static constexpr uint32_t DOUBLE_TAP_MS = 400; + + void press() { + held_ = true; + used_while_held_ = false; + } + + void release(uint32_t now_ms) { + held_ = false; + if (discard_release_) { + discard_release_ = false; + used_while_held_ = false; + return; + } + if (used_while_held_) return; + tap(now_ms); + } + + void tap(uint32_t now_ms) { + if (locked_) { + clearLatched(); + return; + } + if (one_shot_ && (uint32_t)(now_ms - last_tap_ms_) <= DOUBLE_TAP_MS) { + one_shot_ = false; + locked_ = true; + last_tap_ms_ = 0; + return; + } + one_shot_ = true; + last_tap_ms_ = now_ms; + } + + bool consumeForKey() { + const bool active_now = held_ || one_shot_ || locked_; + if (held_) used_while_held_ = true; + if (one_shot_) { + one_shot_ = false; + last_tap_ms_ = 0; + } + return active_now; + } + + void markHeldUsed() { + if (!held_) return; + used_while_held_ = true; + one_shot_ = false; + last_tap_ms_ = 0; + } + + void clearLatched() { + one_shot_ = false; + locked_ = false; + last_tap_ms_ = 0; + } + + void discard() { + clearLatched(); + if (held_) { + used_while_held_ = true; + discard_release_ = true; + } + } + + void baseline(bool physically_held) { + clearLatched(); + held_ = physically_held; + used_while_held_ = physically_held; + discard_release_ = physically_held; + } + + bool held() const { return held_; } + bool latched() const { return one_shot_ || locked_; } + bool active() const { return held_ || one_shot_ || locked_; } + bool locked() const { return locked_; } + + private: + bool held_ = false; + bool used_while_held_ = false; + bool one_shot_ = false; + bool locked_ = false; + bool discard_release_ = false; + uint32_t last_tap_ms_ = 0; +}; \ No newline at end of file diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp index e357368b..4e39a1db 100644 --- a/src/helpers/input/PagerKeyboard.cpp +++ b/src/helpers/input/PagerKeyboard.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include "PagerKeyboardState.h" #ifndef KB_INT #define KB_INT 6 @@ -14,30 +14,10 @@ #ifndef KB_BACKLIGHT #define KB_BACKLIGHT 46 #endif -#define KB_ROWS 4 -#define KB_COLS 10 - -// Matrix legend, s_keymap[row][col] — same physical keyboard PCB as -// trail-mate's working LR1121 pager build, cross-checked there. '\0' = no -// character at that position (dead cell, or intercepted as a modifier below). -static constexpr char s_keymap[KB_ROWS][KB_COLS] = { - {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, - {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'}, - {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'}, - {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, -}; -// Alt layer (symbols/numbers) — this hardware has no separate physical Symbol -// key, so Alt alone drives it (matches trail-mate's has_symbol_key=false path). -static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { - {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, - {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'}, - {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'}, - {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, -}; - // Modifier/special-key positions: 0-based (row*KB_COLS + col), matching the -// TCA8418 raw event's (code & 0x7F) - 1. Alt is a hold (symbol layer while -// held); Shift is a hold too (momentary uppercase on the base layer, real +// TCA8418 raw event's (code & 0x7F) - 1. Alt selects symbols while held, a +// solo tap selects them for one key, and a double tap locks the layer. Shift +// is a hold too (momentary uppercase on the base layer, real // Shift-key semantics) — held Alt THEN a Shift press instead chords into // Alt+Shift, reported via s_alt_shift_chord_pending. What that chord DOES is // a UI-level decision (UITask.cpp): Caps Lock toggle while editing a text @@ -55,29 +35,21 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = { // harmless in practice since the intended gesture is // hold-Alt-tap-Shift-release-both, not holding all three simultaneously. // Backspace (row2,col9) sits one column over from Shift, so Alt+Backspace -// doesn't share this exact ghosting risk with any base-layer letter. -static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers) -static constexpr uint8_t kShiftPos = 2 * KB_COLS + 8; // row2,col8 ('\0' in both layers) -static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in both layers) -static constexpr uint8_t kSpacePos = 3 * KB_COLS + 0; // row3,col0 (' ' in both layers) +// doesn't share this exact ghosting risk with any base-layer letter. The maps +// and positions live in PagerKeyboardState so host tests exercise the exact +// production translation logic. static Adafruit_TCA8418 s_kb; static bool s_inited = false; -static bool s_alt = false; -static bool s_alt_used = false; // Alt consumed as a modifier since it was last pressed -static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile -static bool s_caps = false; -static bool s_shift_held = false; // momentary Shift, mirrors s_backspace_held/s_space_held -static bool s_alt_shift_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltShiftChord() -static bool s_alt_backspace_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltBackspaceChord() -static bool s_backspace_held = false; -static bool s_space_held = false; +static PagerKeyboardState s_state; -// Single-producer (poll) / single-consumer (UI thread) ring — same pattern as -// TDeckKeyboard.cpp; byte indices are atomic enough for SPSC without a lock. -static volatile uint8_t s_ring[16]; -static volatile uint8_t s_head = 0; -static volatile uint8_t s_tail = 0; +// Single-producer (poll) / single-consumer ring. Polling currently happens on +// the UI task, but the short critical section preserves the header's contract +// if a future board moves I2C polling to another core. +static portMUX_TYPE s_ring_mux = portMUX_INITIALIZER_UNLOCKED; +static uint8_t s_ring[16]; +static uint8_t s_head = 0; +static uint8_t s_tail = 0; static bool s_bl_ready = false; // This framework's Arduino-ESP32 core only has the channel-based LEDC API @@ -88,16 +60,19 @@ static bool s_bl_ready = false; static constexpr uint8_t kKbBacklightPwmChannel = 0; static void ringPush(uint8_t c) { + portENTER_CRITICAL(&s_ring_mux); const uint8_t nh = (uint8_t)((s_head + 1) & 15); if (nh != s_tail) { // drop if the ring is full s_ring[s_head] = c; s_head = nh; } + portEXIT_CRITICAL(&s_ring_mux); } void pagerKeyboardBegin() { if (s_inited) return; - s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && s_kb.matrix(KB_ROWS, KB_COLS); + s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && + s_kb.matrix(PagerKeyboardState::ROWS, PagerKeyboardState::COLS); if (!s_inited) return; s_kb.flush(); pinMode(KB_INT, INPUT_PULLUP); // TCA8418 INT is open-drain active-low; not ISR-driven here (see .h) @@ -115,58 +90,20 @@ void pagerKeyboardPoll() { const bool pressed = (raw & 0x80) != 0; const uint8_t code = (uint8_t)((raw & 0x7F) - 1); - if (code == kAltPos) { - // Solo tap (press+release, nothing else in between) vs. a modifier hold - // (symbol-layer typing, or the rotary encoder's Alt+turn via - // pagerKeyboardMarkAltUsed()) — only the former queues a pending tap. - if (pressed) { s_alt = true; s_alt_used = false; } - else { if (!s_alt_used) s_alt_tap_pending = true; s_alt = false; } - continue; - } - // Any other key event while Alt is held means Alt is being used as a - // modifier, not tapped solo — cancels the pending-tap interpretation. - if (s_alt && pressed) s_alt_used = true; - if (code == kShiftPos) { - if (pressed) { - if (s_alt) s_alt_shift_chord_pending = true; // Alt (Fn) held + Shift press = chord (UI decides the effect) - else s_shift_held = true; - } else { - s_shift_held = false; - } - continue; - } - if (code == kBackspacePos) { - if (pressed) { - // Alt+Backspace is a distinct chord (jump Home, everywhere -- see - // pagerKeyboardConsumeAltBackspaceChord()), not a delete: suppress - // both the '\b' ring-push AND s_backspace_held, so the plain- - // Backspace hold gestures (back / unlock) never also see this press. - if (s_alt) s_alt_backspace_chord_pending = true; - else { s_backspace_held = true; ringPush('\b'); } - } else { - s_backspace_held = false; - } - continue; - } - if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; } - if (!pressed) continue; // base/symbol keys only emit on press - - const uint8_t row = code / KB_COLS; - const uint8_t col = code % KB_COLS; - if (row >= KB_ROWS) continue; // a GPIO event outside the matrix, not a key - - char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col]; - if (c == '\0') continue; - // Caps Lock and a held Shift both mean "uppercase," base layer only. - if ((s_caps || s_shift_held) && !s_alt) c = (char)toupper((unsigned char)c); - ringPush((uint8_t)c); + const uint8_t key = s_state.event(code, pressed, millis()); + if (key) ringPush(key); } } int pagerKeyboardReadKey() { - if (s_tail == s_head) return 0; + portENTER_CRITICAL(&s_ring_mux); + if (s_tail == s_head) { + portEXIT_CRITICAL(&s_ring_mux); + return 0; + } const uint8_t c = s_ring[s_tail]; s_tail = (uint8_t)((s_tail + 1) & 15); + portEXIT_CRITICAL(&s_ring_mux); return c; } @@ -179,32 +116,26 @@ void pagerKeyboardSetBacklight(uint8_t level) { ledcWrite(kKbBacklightPwmChannel, level); } -bool pagerKeyboardAltHeld() { return s_alt; } +bool pagerKeyboardAltHeld() { return s_state.altHeld(); } -void pagerKeyboardMarkAltUsed() { s_alt_used = true; } +void pagerKeyboardMarkAltUsed() { s_state.markAltUsed(); } -bool pagerKeyboardConsumeAltTap() { - if (!s_alt_tap_pending) return false; - s_alt_tap_pending = false; - return true; +void pagerKeyboardDiscardAlt() { + s_state.discardAlt(); } -bool pagerKeyboardBackspaceHeld() { return s_backspace_held; } +bool pagerKeyboardBackspaceHeld() { return s_state.backspaceHeld(); } -bool pagerKeyboardSpaceHeld() { return s_space_held; } +bool pagerKeyboardSpaceHeld() { return s_state.spaceHeld(); } bool pagerKeyboardConsumeAltShiftChord() { - if (!s_alt_shift_chord_pending) return false; - s_alt_shift_chord_pending = false; - return true; + return s_state.consumeAltShiftChord(); } -void pagerKeyboardToggleCaps() { s_caps = !s_caps; } +void pagerKeyboardToggleCaps() { s_state.toggleCaps(); } bool pagerKeyboardConsumeAltBackspaceChord() { - if (!s_alt_backspace_chord_pending) return false; - s_alt_backspace_chord_pending = false; - return true; + return s_state.consumeAltBackspaceChord(); } #endif diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h index 774e5525..7d3eca4e 100644 --- a/src/helpers/input/PagerKeyboard.h +++ b/src/helpers/input/PagerKeyboard.h @@ -3,8 +3,8 @@ // T-LoRa Pager physical QWERTY keyboard: a TCA8418 I2C matrix controller (4 // rows x 10 cols, addr 0x34) on the shared I2C bus (SDA 3 / SCL 2). Unlike -// the T-Deck's keyboard (a second MCU that resolves ASCII itself before we -// ever see a byte), the TCA8418 only reports raw row/col matrix events — the +// the T-Deck's keyboard (a second MCU, with raw mode only on newer firmware), +// the TCA8418 always reports raw row/col matrix events — the // keymap + shift/sym/alt state machine lives HERE, so pagerKeyboardReadKey() // produces the same final ASCII/control-code stream handleHwKey() already // expects from the T-Deck; no UI-side changes needed to consume it. @@ -13,7 +13,7 @@ // must be called from a single, consistent context each tick (whichever task // ends up owning it — wired in a later milestone; this board has no // pre-existing shared-bus task the way the T-Deck's touch poll does). -// pagerKeyboardReadKey() only pops from a lock-free ring and is safe to call +// pagerKeyboardReadKey() only pops from a critical-section-protected ring and is safe to call // from the UI thread regardless of which context polls. #if defined(HAS_PAGER_KEYBOARD) && defined(ESP32) @@ -46,15 +46,12 @@ bool pagerKeyboardAltHeld(); /** Mark the currently-held Alt as "used as a modifier" — call this when some * other gesture (e.g. the rotary encoder's Alt+turn) consumes the hold, so - * releasing Alt afterward isn't also read as a solo tap by - * pagerKeyboardConsumeAltTap(). */ + * releasing Alt afterward does not arm the one-shot symbol layer. */ void pagerKeyboardMarkAltUsed(); -/** One-shot: true exactly once if Alt was pressed and released without being - * used as a modifier for anything else in between (no key typed, no - * pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a - * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */ -bool pagerKeyboardConsumeAltTap(); +/** Cancel one-shot/locked Alt, suppress a later release from a held Alt, and + * discard pending Alt chords. Used when input is intentionally discarded. */ +void pagerKeyboardDiscardAlt(); /** True while Backspace is physically held WITHOUT Alt (raw state, mirrors * pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b' diff --git a/src/helpers/input/PagerKeyboardState.h b/src/helpers/input/PagerKeyboardState.h new file mode 100644 index 00000000..f2c77fc4 --- /dev/null +++ b/src/helpers/input/PagerKeyboardState.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +#include "LatchedModifier.h" + +class PagerKeyboardState { + public: + static constexpr uint8_t ROWS = 4; + static constexpr uint8_t COLS = 10; + static constexpr uint8_t ALT_POS = 2 * COLS; + static constexpr uint8_t SHIFT_POS = 2 * COLS + 8; + static constexpr uint8_t BACKSPACE_POS = 2 * COLS + 9; + static constexpr uint8_t SPACE_POS = 3 * COLS; + + uint8_t event(uint8_t code, bool pressed, uint32_t now_ms) { + static const char base[ROWS][COLS] = { + {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'}, + {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'}, + {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, + }; + static const char symbols[ROWS][COLS] = { + {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, + {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'}, + {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'}, + {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'}, + }; + + if (code == ALT_POS) { + if (pressed) alt_.press(); + else alt_.release(now_ms); + return 0; + } + if (code == SHIFT_POS) { + if (pressed) { + if (alt_.held()) { + alt_.markHeldUsed(); + alt_shift_chord_pending_ = true; + } else { + shift_held_ = true; + } + } else { + shift_held_ = false; + } + return 0; + } + if (code == BACKSPACE_POS) { + if (pressed) { + if (alt_.held()) { + alt_.markHeldUsed(); + alt_backspace_chord_pending_ = true; + } else { + alt_.consumeForKey(); + backspace_held_ = true; + return '\b'; + } + } else { + backspace_held_ = false; + } + return 0; + } + if (code == SPACE_POS) { + space_held_ = pressed; + if (pressed) { + alt_.consumeForKey(); + return ' '; + } + return 0; + } + if (!pressed) return 0; + + const uint8_t row = code / COLS; + const uint8_t col = code % COLS; + if (row >= ROWS) return 0; + const bool symbol_layer = alt_.consumeForKey(); + char key = symbol_layer ? symbols[row][col] : base[row][col]; + if (key == '\0') return 0; + if ((caps_ || shift_held_) && !symbol_layer && key >= 'a' && key <= 'z') key -= 32; + return (uint8_t)key; + } + + bool altHeld() const { return alt_.held(); } + void markAltUsed() { alt_.markHeldUsed(); } + void discardAlt() { + alt_.discard(); + alt_shift_chord_pending_ = false; + alt_backspace_chord_pending_ = false; + } + bool backspaceHeld() const { return backspace_held_; } + bool spaceHeld() const { return space_held_; } + bool consumeAltShiftChord() { + const bool pending = alt_shift_chord_pending_; + alt_shift_chord_pending_ = false; + return pending; + } + void toggleCaps() { caps_ = !caps_; } + bool consumeAltBackspaceChord() { + const bool pending = alt_backspace_chord_pending_; + alt_backspace_chord_pending_ = false; + return pending; + } + + private: + LatchedModifier alt_; + bool caps_ = false; + bool shift_held_ = false; + bool alt_shift_chord_pending_ = false; + bool alt_backspace_chord_pending_ = false; + bool backspace_held_ = false; + bool space_held_ = false; +}; \ No newline at end of file diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp index 091b6b33..8e911e8a 100644 --- a/src/helpers/input/TDeckKeyboard.cpp +++ b/src/helpers/input/TDeckKeyboard.cpp @@ -2,6 +2,7 @@ #if defined(HAS_TDECK_KEYBOARD) && defined(ESP32) #include "TDeckKeyboard.h" +#include "TDeckKeyboardState.h" #include #include @@ -9,19 +10,91 @@ #define PIN_KB_ADDR 0x55 #endif -// Single-producer (core-0 poll) / single-consumer (UI thread) ring. Byte indices -// on a 32-bit MCU are atomic enough for SPSC without a lock. -static volatile uint8_t s_ring[16]; -static volatile uint8_t s_head = 0; // written by producer -static volatile uint8_t s_tail = 0; // written by consumer +// Core-0 produces and the UI task consumes. One short cross-core critical +// section publishes each byte together with its index and also protects the +// desired modifier-input mode below. +static portMUX_TYPE s_keyboard_mux = portMUX_INITIALIZER_UNLOCKED; +static uint8_t s_ring[16]; +static uint8_t s_head = 0; +static uint8_t s_tail = 0; static bool s_inited = false; +enum class KeyboardMode : uint8_t { Probe, Raw, Legacy }; +static KeyboardMode s_mode = KeyboardMode::Probe; +static TDeckKeyboardState s_raw_state; +static bool s_modifier_input_allowed = true; // guarded by s_keyboard_mux +static uint32_t s_modifier_mode_generation = 0; // guarded by s_keyboard_mux +static uint32_t s_modifier_generation_applied = 0; // core-0 owner only // Backlight: the UI thread requests a level; the actual I2C write happens in the // poll (core 0). The keyboard's C3 firmware sets the backlight on an I2C write. static volatile uint8_t s_bl_desired = 0; static volatile bool s_bl_dirty = false; +static void ringPushLocked(uint8_t key) { + if (!key) return; + const uint8_t next = (uint8_t)((s_head + 1) & 15); + if (next != s_tail) { + s_ring[s_head] = key; + s_head = next; + } +} + +static void keyboardCommand(uint8_t command) { + Wire.beginTransmission(PIN_KB_ADDR); + Wire.write(command); + Wire.endTransmission(); +} + +static int keyboardRead(uint8_t* out, size_t count) { + Wire.requestFrom((int)PIN_KB_ADDR, (int)count); + int read = 0; + while (Wire.available() && read < (int)count) out[read++] = (uint8_t)Wire.read(); + while (Wire.available()) Wire.read(); + return read; +} + +static void processRawFrame(const uint8_t frame[TDeckKeyboardState::COLS], uint32_t now_ms) { + uint8_t keys[16]; + portENTER_CRITICAL(&s_keyboard_mux); + const uint32_t generation = s_modifier_mode_generation; + if (generation != s_modifier_generation_applied) { + // Transition frame: establish current physical state, publish nothing. + // The mode setter already cleared older queued bytes under this same lock. + s_raw_state.baseline(frame); + s_modifier_generation_applied = generation; + } else { + const size_t key_count = s_raw_state.update(frame, now_ms, keys, sizeof keys, + s_modifier_input_allowed); + for (size_t i = 0; i < key_count; ++i) ringPushLocked(keys[i]); + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + +static void processLegacyKey(uint8_t key) { + portENTER_CRITICAL(&s_keyboard_mux); + const uint32_t generation = s_modifier_mode_generation; + if (generation != s_modifier_generation_applied) { + // No raw modifier state exists, and the C3 exposes one latest-byte mailbox + // (comdata/comdata_flag), not one response per host mode transition. Any + // number of transitions before this read therefore coalesce deliberately: + // drop the sole pending byte once so it cannot cross into the final mode. + s_modifier_generation_applied = generation; + } else { + ringPushLocked(key); + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + void tdeckKeyboardBegin() { + s_mode = KeyboardMode::Probe; + s_raw_state = TDeckKeyboardState{}; + portENTER_CRITICAL(&s_keyboard_mux); + s_head = s_tail = 0; + // Keep any desired mode the UI published immediately after starting this + // task. Setting applied one generation behind forces the first response to + // baseline (or drain the legacy controller's one-byte mailbox). + s_modifier_generation_applied = s_modifier_mode_generation - 1; + portEXIT_CRITICAL(&s_keyboard_mux); s_inited = true; // Wire was configured (18/8, 400k, 20ms timeout) by the touch driver } @@ -49,21 +122,69 @@ void tdeckKeyboardFlushBacklight() { void tdeckKeyboardPoll() { if (!s_inited) return; tdeckKeyboardFlushBacklight(); - if (Wire.requestFrom((int)PIN_KB_ADDR, 1) != 1) return; - uint8_t key = Wire.read(); - if (key == 0) return; // no key this scan - const uint8_t nh = (uint8_t)((s_head + 1) & 15); - if (nh != s_tail) { // drop if the ring is full - s_ring[s_head] = key; - s_head = nh; + if (s_mode == KeyboardMode::Probe) { + // LilyGO keyboard firmware from June 2025 onward accepts 0x03 and returns + // five column-state bytes. Older controllers ignore it and keep returning + // one resolved ASCII byte; restore 0x04 key mode immediately in that case. + keyboardCommand(0x03); + uint8_t frame[TDeckKeyboardState::COLS] = {}; + const int count = keyboardRead(frame, sizeof frame); + bool valid_raw = count == (int)sizeof frame; + for (size_t col = 0; col < sizeof frame && valid_raw; ++col) + valid_raw = (frame[col] & 0x80) == 0; + if (valid_raw) { + s_mode = KeyboardMode::Raw; + Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled"); + processRawFrame(frame, millis()); + return; + } + keyboardCommand(0x04); + s_mode = KeyboardMode::Legacy; + Serial.println("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier latching"); + if (count == 1) processLegacyKey(frame[0]); + return; } + if (s_mode == KeyboardMode::Raw) { + uint8_t frame[TDeckKeyboardState::COLS] = {}; + if (keyboardRead(frame, sizeof frame) != (int)sizeof frame) return; + for (size_t col = 0; col < sizeof frame; ++col) if (frame[col] & 0x80) return; + processRawFrame(frame, millis()); + return; + } + uint8_t key = 0; + if (keyboardRead(&key, 1) == 1) processLegacyKey(key); } int tdeckKeyboardReadKey() { - if (s_tail == s_head) return 0; // empty + portENTER_CRITICAL(&s_keyboard_mux); + if (s_tail == s_head) { + portEXIT_CRITICAL(&s_keyboard_mux); + return 0; + } const uint8_t key = s_ring[s_tail]; s_tail = (uint8_t)((s_tail + 1) & 15); + portEXIT_CRITICAL(&s_keyboard_mux); return key; } +void tdeckKeyboardDiscardModifiers() { + portENTER_CRITICAL(&s_keyboard_mux); + if (s_modifier_input_allowed) { + s_modifier_input_allowed = false; + ++s_modifier_mode_generation; + s_tail = s_head; + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + +void tdeckKeyboardAllowModifiers() { + portENTER_CRITICAL(&s_keyboard_mux); + if (!s_modifier_input_allowed) { + s_modifier_input_allowed = true; + ++s_modifier_mode_generation; + s_tail = s_head; + } + portEXIT_CRITICAL(&s_keyboard_mux); +} + #endif diff --git a/src/helpers/input/TDeckKeyboard.h b/src/helpers/input/TDeckKeyboard.h index cf649100..4030bafa 100644 --- a/src/helpers/input/TDeckKeyboard.h +++ b/src/helpers/input/TDeckKeyboard.h @@ -1,14 +1,16 @@ #pragma once // LilyGo T-Deck physical keyboard. It's a separate ESP32-C3 running the -// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55): each -// 1-byte read returns the ASCII code of the last key press (0 = none). The C3 -// resolves shift / sym layers itself, so we just receive final characters. +// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55). +// Current controller firmware supports a five-byte raw matrix mode, which lets +// this driver preserve held modifiers and add tap/double-tap latching. Older +// firmware returns one resolved ASCII byte; startup detects that and restores +// the original controller-side key mode automatically. // // CRITICAL: the keyboard shares the I2C bus with the GT911 touch controller, // which is polled from a core-0 task. To avoid two cores hitting Wire at once, // tdeckKeyboardPoll() must be called from THAT task; the UI thread only drains -// the lock-free ring via tdeckKeyboardReadKey(). +// the critical-section-protected ring via tdeckKeyboardReadKey(). #if defined(HAS_TDECK_KEYBOARD) && defined(ESP32) #include @@ -23,6 +25,14 @@ void tdeckKeyboardPoll(); /** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */ int tdeckKeyboardReadKey(); +/** Cancel one-shot/locked modifiers and suppress a later release from a + * currently held modifier. Call when queued input is intentionally discarded. */ +void tdeckKeyboardDiscardModifiers(); + +/** Stop continuously discarding raw modifier state after the UI returns to a + * mode where keyboard input is meaningful. */ +void tdeckKeyboardAllowModifiers(); + /** Request a keyboard-backlight level (0 = off, 0xFF = on). Safe from the UI * thread — the I2C write happens inside tdeckKeyboardPoll() (core 0), which * owns the bus. Only re-sent when the value changes. */ diff --git a/src/helpers/input/TDeckKeyboardState.h b/src/helpers/input/TDeckKeyboardState.h new file mode 100644 index 00000000..38480462 --- /dev/null +++ b/src/helpers/input/TDeckKeyboardState.h @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include +#include + +#include "LatchedModifier.h" + +class TDeckKeyboardState { + public: + static constexpr size_t COLS = 5; + + size_t update(const uint8_t current[COLS], uint32_t now_ms, + uint8_t* out, size_t out_capacity, bool modifiers_enabled = true) { + static const char base[COLS][7] = { + {'q', 'w', '\0', 'a', '\0', ' ', '\0'}, + {'e', 's', 'd', 'p', 'x', 'z', '\0'}, + {'r', 'g', 't', '\0', 'v', 'c', 'f'}, + {'u', 'h', 'y', '\0', 'b', 'n', 'j'}, + {'o', 'l', 'i', '\0', '$', 'm', 'k'}, + }; + static const char symbols[COLS][7] = { + {'#', '1', '\0', '*', '\0', '\0', '0'}, + {'2', '4', '5', '@', '8', '7', '\0'}, + {'3', '/', '(', '\0', '?', '9', '6'}, + {'_', ':', ')', '\0', '!', ',', ';'}, + {'+', '"', '-', '\0', '\0', '.', '\''}, + }; + auto down = [&](size_t col, uint8_t row) { + return (current[col] & (uint8_t)(1U << row)) != 0; + }; + auto pressed = [&](size_t col, uint8_t row) { + return down(col, row) && (previous_[col] & (uint8_t)(1U << row)) == 0; + }; + auto released = [&](size_t col, uint8_t row) { + return !down(col, row) && (previous_[col] & (uint8_t)(1U << row)) != 0; + }; + + if (!modifiers_enabled) { + symbol_.baseline(down(0, 2)); + alt_.baseline(down(0, 4)); + } + if (modifiers_enabled && pressed(0, 2)) symbol_.press(); + if (modifiers_enabled && pressed(0, 4)) alt_.press(); + if (modifiers_enabled && released(0, 2)) symbol_.release(now_ms); + if (modifiers_enabled && released(0, 4)) alt_.release(now_ms); + + const bool shift = modifiers_enabled && (down(1, 6) || down(2, 3)); + size_t written = 0; + auto emit = [&](uint8_t key) { + if (written < out_capacity) out[written++] = key; + }; + for (uint8_t row = 0; row < 7; ++row) { + for (size_t col = 0; col < COLS; ++col) { + if (!pressed(col, row)) continue; + if ((col == 0 && (row == 2 || row == 4)) || + (col == 1 && row == 6) || (col == 2 && row == 3)) continue; + const bool physical_alt = modifiers_enabled && alt_.held(); + if (physical_alt) alt_.markHeldUsed(); + // Preserve LilyGO controller shortcuts while raw mode moves ordinary + // matrix translation into the host. The C3 still handles Alt+B's + // backlight toggle; suppress its character exactly as key mode does. + if (physical_alt && col == 3 && row == 4) { + symbol_.consumeForKey(); + continue; + } + if (physical_alt && col == 2 && row == 5) { + symbol_.consumeForKey(); + emit(0x0C); + continue; + } + if (col == 3 && row == 3) { + symbol_.consumeForKey(); alt_.consumeForKey(); emit('\r'); continue; + } + if (col == 4 && row == 3) { + symbol_.consumeForKey(); alt_.consumeForKey(); emit('\b'); continue; + } + + const bool use_symbols = modifiers_enabled && (symbol_.active() || alt_.latched()); + const char key = use_symbols ? symbols[col][row] : base[col][row]; + if (modifiers_enabled) { + symbol_.consumeForKey(); + if (!physical_alt) alt_.consumeForKey(); + } + if (key == '\0') continue; + char resolved = key; + if (!use_symbols && shift && resolved >= 'a' && resolved <= 'z') resolved -= 32; + emit((uint8_t)resolved); + } + } + + for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F; + return written; + } + + void baseline(const uint8_t current[COLS]) { + symbol_.baseline((current[0] & (uint8_t)(1U << 2)) != 0); + alt_.baseline((current[0] & (uint8_t)(1U << 4)) != 0); + for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F; + } + + void discardModifiers() { + symbol_.discard(); + alt_.discard(); + } + + private: + uint8_t previous_[COLS] = {}; + LatchedModifier symbol_; + LatchedModifier alt_; +}; \ No newline at end of file diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd95..1e6e4554 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -119,7 +119,7 @@ #include #endif #if defined(HAS_TDECK_KEYBOARD) - #include + #include "../helpers/input/TDeckKeyboard.h" #elif defined(HAS_M9_KEYBOARD) #include #endif @@ -130,7 +130,7 @@ #include // wada.sys.accel() source (luaHostAccel) #endif #if defined(HAS_PAGER_KEYBOARD) - #include + #include "../helpers/input/PagerKeyboard.h" #endif #if defined(HAS_PAGER_ENCODER) #include @@ -36172,30 +36172,6 @@ static void updatePagerBackspaceHold(unsigned long now) { s_was_held = held; } -// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the -// encoder's Alt+turn). In an open chat it takes over the old Backspace role: -// jump to the latest message (when scrolled up in history) and drop focus in -// the composer, ready to type — the natural "done reading, reply now" motion, -// and the deliberate way OUT of the message list now that plain encoder turns -// hold focus inside it while history loads (pagerEncoderChatEdgeScroll). -// Everywhere else it stays the same "next field" as one rotary NEXT detent. -// Call once per loop tick while the screen is on; screen-off handling discards -// any pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray -// tap picked up while idle-dimmed can't fire the moment the screen wakes. -static void updatePagerAltTapNext() { - if (!pagerKeyboardConsumeAltTap()) return; - LvChatPanel* cp = navOpenChatPanel(); - if (cp && cp->msgs && cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) { - if (chatVirtAwayFromBottom(cp)) chatVirtJumpToLatest(cp); - lv_group_focus_obj(cp->composer_ta); - s_nav_show = true; - if (g_lv.task) g_lv.task->noteUserInput(); - return; - } - navPushTap(LV_KEY_NEXT); - if (g_lv.task) g_lv.task->noteUserInput(); -} - // Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has // no UI visibility): toggles Caps Lock while actually editing a text field // (the field is where "Caps Lock" means anything) and is a deliberate no-op @@ -36410,9 +36386,7 @@ static bool pagerEncoderScrollOversizedFocused(bool up) { // the list instead (same navScrollFocused the Alt+turn branch uses): the // scroll fires the virtualization render, the neighbor bubble joins the nav // group via the tree-signature rebuild, and the next detent walks onto it. -// Focus only leaves the list at the TRUE oldest/newest message. The orange-key -// solo tap (updatePagerAltTapNext) pushes LV_KEY_NEXT directly and never comes -// through here, so it stays the deliberate "leave the messages now" exit. +// Focus only leaves the list at the TRUE oldest/newest message. // Returns true when the detent was consumed as a scroll. static bool pagerEncoderChatEdgeScroll(bool up) { LvChatPanel* cp = navOpenChatPanel(); @@ -36583,8 +36557,8 @@ static void updatePagerEncoder(unsigned long now) { if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput(); if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode - // Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a - // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT. + // Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing + // Alt afterward does not also arm the one-shot symbol layer. if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed(); if (s_mentionnav_active) { @@ -38430,9 +38404,9 @@ if (g_lv.task && g_lv.task->isManualLock()) { // below the "NEW ----" unread divider) and focus it, so the user reads the // new messages chronologically with plain encoder turns from there; with // no divider (nothing unread) it jumps to the NEWEST message instead. - // (The old Backspace role -- jump to latest + focus the composer -- moved - // to the solo orange/Alt tap, see updatePagerAltTapNext().) The divider - // jump reuses the exact scroll recipe refreshChatDetail's open-to-divider + // The former solo-Alt shortcut now latches the symbol layer; at the true + // newest message, the next encoder detent leaves the list for the composer. + // The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider // path uses; both cases use the pending-focus re-aim so the recreated row // for the target message ends up focused after the virtualization render. // Outside a chat, Backspace keeps its normal no-op / hold-to-back @@ -52834,6 +52808,8 @@ void UITask::loop() { // started above. bool con_activity = false; #if defined(HAS_TDECK_KEYBOARD) + if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers(); + else tdeckKeyboardAllowModifiers(); for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; @@ -52842,7 +52818,7 @@ void UITask::loop() { // A key pressed on a dark screen WAKES rather than types, the same as a // touch does in the UI. Otherwise the character you used to see the screen // ends up in the command you are typing. - if (_screen_off) { wakeScreen(); continue; } + if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; } consoleKey(key); } #endif @@ -53445,12 +53421,15 @@ void UITask::loop() { updatePagerEncoder(now); #endif #if defined(HAS_TDECK_KEYBOARD) + if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers(); + else tdeckKeyboardAllowModifiers(); // Drain physical-keyboard presses buffered by the touch task into the field. for (int kbi = 0; kbi < 12; ++kbi) { int key = tdeckKeyboardReadKey(); if (key <= 0) break; if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb - if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit + if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit + if (_screen_off) tdeckKeyboardDiscardModifiers(); handleHwKey(key); } // Focusing a text field (cursor starts blinking — tapped or auto-focused) @@ -53492,6 +53471,7 @@ void UITask::loop() { // working while the screen is dark. updatePagerKbBacklight() follows the // same rule. Remote Mode pauses both because it owns the lit placeholder. pagerKeyboardPoll(); + if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt(); // Remote Mode keeps the placeholder lit and reserves all keys for its local // escape path, so normal lock/backlight state machines stay paused there. if (!s_remote_mode) { @@ -53507,7 +53487,7 @@ void UITask::loop() { if (key <= 0) break; remotePhysicalKey(key); } - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); } else if (g_lv.task && g_lv.task->isScreenOff()) { @@ -53526,10 +53506,10 @@ void UITask::loop() { any = true; if (k == 0x08) saw_backspace = true; } - // Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while - // idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home) + // Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while + // idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home) // the instant the screen wakes. - pagerKeyboardConsumeAltTap(); + pagerKeyboardDiscardAlt(); pagerKeyboardConsumeAltShiftChord(); pagerKeyboardConsumeAltBackspaceChord(); // Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding @@ -53554,7 +53534,6 @@ void UITask::loop() { if (key <= 0) break; handleHwKey(key); } - updatePagerAltTapNext(); updatePagerAltShiftChord(); updatePagerAltBackspaceChord(); updatePagerBackspaceHold(now); diff --git a/test/test_latched_modifier.cpp b/test/test_latched_modifier.cpp new file mode 100644 index 00000000..c7ade514 --- /dev/null +++ b/test/test_latched_modifier.cpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "helpers/input/LatchedModifier.h" + +int main() { + LatchedModifier modifier; + + modifier.press(); + assert(modifier.consumeForKey()); + modifier.release(100); + assert(!modifier.consumeForKey()); + + modifier.press(); + modifier.release(200); + assert(modifier.consumeForKey()); + assert(!modifier.consumeForKey()); + + modifier.press(); + modifier.release(400); + modifier.press(); + modifier.release(700); + assert(modifier.locked()); + assert(modifier.consumeForKey()); + assert(modifier.consumeForKey()); + + modifier.press(); + modifier.release(800); + assert(!modifier.active()); + + modifier.tap(1000); + modifier.tap(1500); + assert(!modifier.locked()); + assert(modifier.consumeForKey()); + + modifier.tap(2000); + modifier.clearLatched(); + assert(!modifier.active()); + + modifier.tap(2100); + modifier.discard(); + assert(!modifier.active()); + + modifier.press(); + modifier.discard(); + modifier.release(2200); + assert(!modifier.active()); + + LatchedModifier wrapped; + wrapped.tap(UINT32_MAX - 100); + wrapped.tap(100); + assert(wrapped.locked()); + + wrapped.baseline(true); + assert(wrapped.held()); + wrapped.release(200); + assert(!wrapped.active()); + wrapped.baseline(false); + assert(!wrapped.active()); + return 0; +} \ No newline at end of file diff --git a/test/test_pager_keyboard_state.cpp b/test/test_pager_keyboard_state.cpp new file mode 100644 index 00000000..8d428ae9 --- /dev/null +++ b/test/test_pager_keyboard_state.cpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include + +#include "helpers/input/PagerKeyboardState.h" + +int main() { + PagerKeyboardState keyboard; + + assert(keyboard.event(0, true, 10) == 'q'); + keyboard.event(0, false, 20); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 100); + assert(keyboard.altHeld()); + assert(keyboard.event(0, true, 110) == '1'); + keyboard.event(0, false, 120); + keyboard.event(PagerKeyboardState::ALT_POS, false, 130); + assert(keyboard.event(0, true, 140) == 'q'); + keyboard.event(0, false, 150); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 200); + keyboard.event(PagerKeyboardState::ALT_POS, false, 220); + assert(keyboard.event(1, true, 230) == '2'); + keyboard.event(1, false, 240); + assert(keyboard.event(1, true, 250) == 'w'); + keyboard.event(1, false, 260); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 300); + keyboard.event(PagerKeyboardState::ALT_POS, false, 320); + keyboard.event(PagerKeyboardState::ALT_POS, true, 400); + keyboard.event(PagerKeyboardState::ALT_POS, false, 420); + assert(keyboard.event(0, true, 430) == '1'); + keyboard.event(0, false, 440); + assert(keyboard.event(1, true, 450) == '2'); + keyboard.event(1, false, 460); + keyboard.event(PagerKeyboardState::ALT_POS, true, 470); + keyboard.event(PagerKeyboardState::ALT_POS, false, 480); + assert(keyboard.event(0, true, 490) == 'q'); + keyboard.event(0, false, 500); + + keyboard.event(PagerKeyboardState::SHIFT_POS, true, 510); + assert(keyboard.event(0, true, 520) == 'Q'); + keyboard.event(0, false, 530); + keyboard.event(PagerKeyboardState::SHIFT_POS, false, 540); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 600); + keyboard.event(PagerKeyboardState::SHIFT_POS, true, 610); + assert(keyboard.consumeAltShiftChord()); + assert(!keyboard.consumeAltShiftChord()); + keyboard.toggleCaps(); + keyboard.event(PagerKeyboardState::SHIFT_POS, false, 620); + keyboard.event(PagerKeyboardState::ALT_POS, false, 630); + assert(keyboard.event(0, true, 640) == 'Q'); + keyboard.event(0, false, 650); + keyboard.toggleCaps(); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 700); + assert(keyboard.event(PagerKeyboardState::BACKSPACE_POS, true, 710) == 0); + assert(keyboard.consumeAltBackspaceChord()); + assert(!keyboard.backspaceHeld()); + keyboard.event(PagerKeyboardState::BACKSPACE_POS, false, 720); + keyboard.event(PagerKeyboardState::ALT_POS, false, 730); + assert(keyboard.event(0, true, 740) == 'q'); + keyboard.event(0, false, 750); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 800); + keyboard.discardAlt(); + keyboard.event(PagerKeyboardState::ALT_POS, false, 810); + assert(keyboard.event(0, true, 820) == 'q'); + keyboard.event(0, false, 830); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 900); + keyboard.event(PagerKeyboardState::ALT_POS, false, 920); + keyboard.markAltUsed(); // no effect without a physical hold + assert(keyboard.event(PagerKeyboardState::SPACE_POS, true, 930) == ' '); + assert(keyboard.spaceHeld()); + keyboard.event(PagerKeyboardState::SPACE_POS, false, 940); + assert(!keyboard.spaceHeld()); + assert(keyboard.event(0, true, 950) == 'q'); + keyboard.event(0, false, 960); + + keyboard.event(PagerKeyboardState::ALT_POS, true, 1000); + keyboard.markAltUsed(); // physical Alt+encoder equivalent + keyboard.event(PagerKeyboardState::ALT_POS, false, 1010); + assert(keyboard.event(0, true, 1020) == 'q'); + return 0; +} \ No newline at end of file diff --git a/test/test_tdeck_keyboard_state.cpp b/test/test_tdeck_keyboard_state.cpp new file mode 100644 index 00000000..73574350 --- /dev/null +++ b/test/test_tdeck_keyboard_state.cpp @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +#include "helpers/input/TDeckKeyboardState.h" + +static size_t update(TDeckKeyboardState& keyboard, uint8_t state[5], uint32_t now, + uint8_t* out) { + return keyboard.update(state, now, out, 8); +} + +static void setKey(uint8_t state[5], int col, int row, bool down) { + if (down) state[col] |= (uint8_t)(1U << row); + else state[col] &= (uint8_t)~(1U << row); +} + +int main() { + TDeckKeyboardState keyboard; + uint8_t state[5] = {}; + uint8_t out[8] = {}; + + setKey(state, 0, 2, true); // hold Sym + assert(update(keyboard, state, 10, out) == 0); + setKey(state, 0, 0, true); // q -> # + assert(update(keyboard, state, 20, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 30, out); + setKey(state, 0, 2, false); + update(keyboard, state, 40, out); + setKey(state, 0, 0, true); // hold use must not latch + assert(update(keyboard, state, 50, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 60, out); + + setKey(state, 0, 2, true); // tap Sym + update(keyboard, state, 100, out); + setKey(state, 0, 2, false); + update(keyboard, state, 120, out); + setKey(state, 0, 1, true); // w -> 1 once + assert(update(keyboard, state, 130, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 140, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 150, out) == 1 && out[0] == 'w'); + setKey(state, 0, 1, false); + update(keyboard, state, 160, out); + + setKey(state, 0, 2, true); // an unmapped symbol still uses the hold + update(keyboard, state, 170, out); + setKey(state, 0, 5, true); // Space has no T-Deck symbol mapping + assert(update(keyboard, state, 175, out) == 0); + setKey(state, 0, 5, false); + update(keyboard, state, 180, out); + setKey(state, 0, 2, false); + update(keyboard, state, 185, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 190, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 195, out); + + setKey(state, 0, 4, true); // held Alt preserves base layer + update(keyboard, state, 200, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 210, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 215, out); + setKey(state, 3, 4, true); // Alt+B stays controller-only + assert(update(keyboard, state, 216, out) == 0); + setKey(state, 3, 4, false); + update(keyboard, state, 217, out); + setKey(state, 2, 5, true); // Alt+C keeps its legacy control code + assert(update(keyboard, state, 218, out) == 1 && out[0] == 0x0C); + setKey(state, 2, 5, false); + update(keyboard, state, 219, out); + setKey(state, 0, 4, false); + update(keyboard, state, 220, out); + + setKey(state, 0, 4, true); // solo Alt is a one-shot symbol latch + update(keyboard, state, 225, out); + setKey(state, 0, 4, false); + update(keyboard, state, 230, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 235, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 240, out); + + setKey(state, 0, 4, true); // double-tap Alt locks symbols + update(keyboard, state, 250, out); + setKey(state, 0, 4, false); + update(keyboard, state, 270, out); + setKey(state, 0, 4, true); + update(keyboard, state, 300, out); + setKey(state, 0, 4, false); + update(keyboard, state, 320, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 330, out) == 1 && out[0] == '#'); + setKey(state, 0, 0, false); + update(keyboard, state, 340, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 350, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 360, out); + setKey(state, 0, 4, true); // tap Alt unlocks + update(keyboard, state, 400, out); + setKey(state, 0, 4, false); + update(keyboard, state, 420, out); + + setKey(state, 0, 2, true); // double-tap Sym locks symbols + update(keyboard, state, 430, out); + setKey(state, 0, 2, false); + update(keyboard, state, 440, out); + setKey(state, 0, 2, true); + update(keyboard, state, 450, out); + setKey(state, 0, 2, false); + update(keyboard, state, 460, out); + setKey(state, 0, 1, true); + assert(update(keyboard, state, 470, out) == 1 && out[0] == '1'); + setKey(state, 0, 1, false); + update(keyboard, state, 475, out); + setKey(state, 0, 2, true); + update(keyboard, state, 480, out); + setKey(state, 0, 2, false); + update(keyboard, state, 490, out); + + setKey(state, 1, 6, true); // held Shift still uppercases base + update(keyboard, state, 500, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 510, out) == 1 && out[0] == 'Q'); + + setKey(state, 0, 0, false); + setKey(state, 1, 6, false); + update(keyboard, state, 520, out); + setKey(state, 0, 2, true); // discarded physical hold + update(keyboard, state, 600, out); + keyboard.discardModifiers(); + setKey(state, 0, 2, false); + update(keyboard, state, 620, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 630, out) == 1 && out[0] == 'q'); + setKey(state, 0, 0, false); + update(keyboard, state, 640, out); + + setKey(state, 0, 4, true); // discarded Alt hold cannot latch on release + update(keyboard, state, 650, out); + keyboard.discardModifiers(); + setKey(state, 0, 4, false); + update(keyboard, state, 660, out); + setKey(state, 0, 0, true); + assert(update(keyboard, state, 670, out) == 1 && out[0] == 'q'); + + setKey(state, 0, 0, false); + update(keyboard, state, 680, out); + setKey(state, 0, 2, true); // modifiers disabled: base key still routes + update(keyboard, state, 690, out); + setKey(state, 0, 0, true); + assert(keyboard.update(state, 700, out, 8, false) == 1 && out[0] == 'q'); + + uint8_t baseline[5] = {}; + setKey(baseline, 0, 1, true); // transition frame produces no edge later + keyboard.baseline(baseline); + assert(keyboard.update(baseline, 710, out, 8) == 0); + return 0; +} \ No newline at end of file