This commit is contained in:
Kaj Schittecat committed 2026-08-27 10:28:55 +02:00
commit 9fb65f91c4
13 files changed
+850 -154

No files matched your search

+6
View File
@@ -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
+6 -6
View File
@@ -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 ⑩) |
+15 -22
View File
@@ -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 |
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stdint.h>
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;
};
+38 -101
View File
@@ -6,7 +6,7 @@
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_TCA8418.h>
#include <cctype>
#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,11 +116,12 @@ 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(); }
void pagerKeyboardDiscardAlt() {
<<<<<<< HEAD
s_alt = false;
s_alt_used = true;
s_alt_tap_pending = false;
@@ -195,24 +133,23 @@ bool pagerKeyboardConsumeAltTap() {
if (!s_alt_tap_pending) return false;
s_alt_tap_pending = false;
return true;
=======
s_state.discardAlt();
>>>>>>> pr/320
}
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
+10 -5
View File
@@ -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,10 +46,10 @@ 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();
<<<<<<< HEAD
/** Clear held/tapped Alt and pending Alt chords when keyboard input is
* intentionally discarded during a UI mode transition. */
void pagerKeyboardDiscardAlt();
@@ -59,6 +59,11 @@ void pagerKeyboardDiscardAlt();
* 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();
>>>>>>> pr/320
/** True while Backspace is physically held WITHOUT Alt (raw state, mirrors
* pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b'
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stdint.h>
#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;
};
+136 -13
View File
@@ -2,6 +2,7 @@
#if defined(HAS_TDECK_KEYBOARD) && defined(ESP32)
#include "TDeckKeyboard.h"
#include "TDeckKeyboardState.h"
#include <Arduino.h>
#include <Wire.h>
@@ -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,25 +122,75 @@ 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;
}
<<<<<<< HEAD
void tdeckKeyboardDiscardModifiers() {}
void tdeckKeyboardAllowModifiers() {}
=======
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);
}
>>>>>>> pr/320
#endif
+11 -7
View File
@@ -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 <stdint.h>
@@ -23,10 +25,12 @@ void tdeckKeyboardPoll();
/** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */
int tdeckKeyboardReadKey();
/** Compatibility hooks for UI mode transitions. Modifier state belongs to
* the keyboard's C3 firmware in this legacy ASCII driver, so there is no
* host-side state to clear or re-enable. */
/** 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
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <stddef.h>
#include <stdint.h>
#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_;
};
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#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;
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#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;
}
+164
View File
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include <assert.h>
#include <string.h>
#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;
}