feat(T9): 九宫格输入法 + 多项 bug 修复

- T9 multi-tap 输入法:abc/ABC/123 三模式切换
- 修复 commitMultiTap 无限递归崩溃(重入保护)
- 修复 payload 残留导致非预期行为
- 修复 REGENERATE_FRAMESET 重复触发导致跳回主页面
- 修复大小写模式下出现数字(t9LetterMap 重做)
- 修复光标不跟随预览字符跳转(displayCursor +1)
- 修复按 0 无法输出空格(multiTapKey 无效标记 0→0xFF)
This commit is contained in:
2026-03-29 18:29:52 +08:00
parent eacbbc08dc
commit 614c0f77e8
3 changed files with 201 additions and 2 deletions
+20
View File
@@ -78,6 +78,14 @@
#### 充电检测轮询间隔缩短 #### 充电检测轮询间隔缩短
- TCA9535 CHARGE_DET 轮询间隔从 2000ms 缩短到 500ms,加快充电状态响应 - TCA9535 CHARGE_DET 轮询间隔从 2000ms 缩短到 500ms,加快充电状态响应
#### T9 九宫格输入法(esp32c3_moonshine_travelers
- 三种输入模式:abc(小写) / ABC(大写) / 123(数字),按 `#` 循环切换
- 数字键 2-9:大小写模式下 multi-tap 选字母(800ms 超时自动确认),数字模式下直接输出数字
- 数字键 0 = 空格(大小写模式) / 数字 0(数字模式)
- 数字键 1 = 标点循环 `. , ! ?`(大小写模式) / 数字 1(数字模式)
- `*` 号键 = 退格(backspace
- 屏幕右下角显示当前输入模式标签,光标位置实时预览 multi-tap 字符
#### 按键映射更新(key3/key7/key11/key15 = 方向键) #### 按键映射更新(key3/key7/key11/key15 = 方向键)
- 矩阵按键映射从 `key1=UP, key2=DOWN, key3=LEFT, key4=RIGHT` 改为 `key3=UP, key7=DOWN, key11=LEFT, key15=RIGHT` - 矩阵按键映射从 `key1=UP, key2=DOWN, key3=LEFT, key4=RIGHT` 改为 `key3=UP, key7=DOWN, key11=LEFT, key15=RIGHT`
- 方向键全部位于 COL3 列(key3=ROW0·COL3, key7=ROW1·COL3, key11=ROW2·COL3, key15=ROW3·COL3 - 方向键全部位于 COL3 列(key3=ROW0·COL3, key7=ROW1·COL3, key11=ROW2·COL3, key15=ROW3·COL3
@@ -95,6 +103,18 @@
- **LTO 链接错误**:编译时 `-flto` 导致 `undefined reference to TCA9535ButtonThread::*` - **LTO 链接错误**:编译时 `-flto` 导致 `undefined reference to TCA9535ButtonThread::*`
- 原因:.h/.cpp 中的 `#if defined(HAS_TCA9535_BUTTON)` 守卫导致部分编译单元中符号被丢弃 - 原因:.h/.cpp 中的 `#if defined(HAS_TCA9535_BUTTON)` 守卫导致部分编译单元中符号被丢弃
- 修复:去掉 .h/.cpp 中的条件守卫,类定义和实现始终编译;main.cpp 中的实例化仍由 `#ifdef` 控制 - 修复:去掉 .h/.cpp 中的条件守卫,类定义和实现始终编译;main.cpp 中的实例化仍由 `#ifdef` 控制
- **T9 commitMultiTap 无限递归崩溃**`commitMultiTap()``runOnce()` → 检测超时又调 `commitMultiTap()` → 栈溢出
- 修复:添加 `committingMultiTap` 重入保护标志
- **T9 payload 残留导致非预期行为**:`runOnce()` 调用后 payload 未清零,被下次调度复用
- 修复:所有手动 `runOnce()` 调用后立即 `payload = 0`,末尾加最后防线清零
- **T9 REGENERATE_FRAMESET 重复触发导致跳回主页面**:同一调用链中多次 `notifyObservers(REGENERATE_FRAMESET)` 导致 `requestFocus()` 被消费后 `focusedModule=255` → 跳第一帧
- 修复:`commitMultiTap` 移除多余通知,`showMultiTapPreview`/`#`键改用 `REDRAW_ONLY`,超时 commit 后跳过后续通知,LEFT/RIGHT 补加 `requestFocus()`
- **T9 大小写模式下出现数字**:旧 t9Map index 0 存放数字,大小写模式下仍会显示
- 修复:重做 `t9LetterMap`,去掉数字项,DIGIT 模式下直接输出数字不走 multi-tap
- **T9 光标不跟随预览字符跳转**:preview 字符插入 `displayText``displayCursor` 未 +1
- 修复:有 pending multi-tap 时 `displayCursor = cursor + 1`
- **T9 按 0 无法输出空格**`multiTapKey``0` 表示"无 pending",与按键 0 的值冲突
- 修复:无效标记从 `0` 改为 `0xFF`
--- ---
@@ -13,6 +13,25 @@
#include "detect/ScanI2C.h" #include "detect/ScanI2C.h"
#include "graphics/Screen.h" #include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h" #include "graphics/SharedUIDisplay.h"
// === T9 multi-tap letter mapping (standard phone keypad) ===
// Each row maps a digit key to the characters available in LETTER mode (UPPER/LOWER).
// Index 0 = first letter, 1 = second, etc. Terminated by nullptr.
// In DIGIT mode, the key simply produces its digit value directly.
// Special keys: '0' = space in letter mode, digit in digit mode
// '1' = punctuation in letter mode, digit in digit mode
const char *const CannedMessageModule::t9LetterMap[][5] = {
/* '0' */ {" ", nullptr}, // space only
/* '1' */ {".", ",", "!", "?", nullptr}, // punctuation
/* '2' */ {"a", "b", "c", nullptr},
/* '3' */ {"d", "e", "f", nullptr},
/* '4' */ {"g", "h", "i", nullptr},
/* '5' */ {"j", "k", "l", nullptr},
/* '6' */ {"m", "n", "o", nullptr},
/* '7' */ {"p", "q", "r", "s"},
/* '8' */ {"t", "u", "v", nullptr},
/* '9' */ {"w", "x", "y", "z"},
};
#include "graphics/draw/NotificationRenderer.h" #include "graphics/draw/NotificationRenderer.h"
#include "graphics/emotes.h" #include "graphics/emotes.h"
#include "graphics/images.h" #include "graphics/images.h"
@@ -841,6 +860,7 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
// Confirm select (Enter) // Confirm select (Enter)
bool isSelect = isSelectEvent(event); bool isSelect = isSelectEvent(event);
if (isSelect) { if (isSelect) {
commitMultiTap();
LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel, LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel,
freetext.c_str()); freetext.c_str());
if (dest == 0) if (dest == 0)
@@ -862,12 +882,15 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
payload = 0x08; payload = 0x08;
lastTouchMillis = millis(); lastTouchMillis = millis();
runOnce(); runOnce();
payload = 0;
return true; return true;
} }
// LEFT/RIGHT in FREETEXT: go back to canned message list (ACTIVE), preserving input // LEFT/RIGHT in FREETEXT: go back to canned message list (ACTIVE), preserving input
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) { if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) {
commitMultiTap();
runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
requestFocus(); // commitMultiTap's runOnce() consumed the previous focus request
UIFrameEvent e; UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e); notifyObservers(&e);
@@ -878,6 +901,7 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
// Cancel (dismiss freetext screen) // Cancel (dismiss freetext screen)
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG || if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG ||
(event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() == 0)) { (event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() == 0)) {
multiTapKey = 0xFF; // discard pending multi-tap
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
freetext = ""; freetext = "";
cursor = 0; cursor = 0;
@@ -902,14 +926,70 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
payload = 0x08; payload = 0x08;
lastTouchMillis = millis(); lastTouchMillis = millis();
runOnce(); runOnce();
payload = 0;
return true; return true;
} }
// Printable ASCII (add char to draft) // '#' key: cycle input mode DIGIT -> LOWER -> UPPER -> DIGIT ...
if (event->kbchar == '#') {
// First, commit any pending multi-tap character
commitMultiTap();
int m = static_cast<int>(inputMode);
inputMode = static_cast<InputMode>((m + 1) % 3);
LOG_DEBUG("[T9] Input mode: %d", (int)inputMode);
lastTouchMillis = millis();
// Use REDRAW_ONLY: commitMultiTap() already triggered REGENERATE_FRAMESET,
// another one would lose focus → jump to main screen
UIFrameEvent e;
e.action = UIFrameEvent::Action::REDRAW_ONLY;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
// Multi-tap T9 for digit keys '0'-'9' from TCA9535 numpad
if (event->kbchar >= '0' && event->kbchar <= '9') {
uint32_t now = millis();
uint8_t key = event->kbchar - '0'; // 0-9
// In DIGIT mode, digit keys produce their digit directly — no multi-tap cycling
if (inputMode == InputMode::DIGIT) {
commitMultiTap(); // commit any pending character first
payload = '0' + key;
lastTouchMillis = millis();
runOnce();
payload = 0;
return true;
}
// In UPPER/LOWER mode: use letter mapping with multi-tap cycling
// Check if multi-tap timed out or different key pressed
if (multiTapKey != key || (now - multiTapLastMs >= MULTI_TAP_TIMEOUT_MS)) {
// Commit previous pending char, start new multi-tap sequence
commitMultiTap();
multiTapKey = key;
multiTapIndex = 0; // index 0 = first letter in t9LetterMap
} else {
// Same key, within timeout → advance index
int count = 0;
while (t9LetterMap[key][count] != nullptr) count++;
multiTapIndex = (multiTapIndex + 1) % count;
}
multiTapLastMs = now;
// Show preview character (will be committed on timeout or next key)
showMultiTapPreview();
lastTouchMillis = millis();
return true;
}
// Any other printable ASCII from external keyboards: commit multi-tap first, then insert
if (event->kbchar >= 32 && event->kbchar <= 126) { if (event->kbchar >= 32 && event->kbchar <= 126) {
commitMultiTap();
payload = event->kbchar; payload = event->kbchar;
lastTouchMillis = millis(); lastTouchMillis = millis();
runOnce(); runOnce();
payload = 0;
return true; return true;
} }
@@ -1190,6 +1270,18 @@ int32_t CannedMessageModule::runOnce()
LOG_DEBUG("MOVE DOWN (%d):%s", this->currentMessageIndex, this->getCurrentMessage()); LOG_DEBUG("MOVE DOWN (%d):%s", this->currentMessageIndex, this->getCurrentMessage());
} }
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { } else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) {
// Multi-tap timeout: auto-commit pending character (with reentrancy guard)
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->multiTapKey != 0xFF &&
!this->committingMultiTap &&
(millis() - this->multiTapLastMs >= MULTI_TAP_TIMEOUT_MS)) {
if (commitMultiTap()) {
// commitMultiTap() already called runOnce() which did notifyObservers(REGENERATE_FRAMESET).
// Do NOT call it again here — a second REGENERATE_FRAMESET would cause
// setFrames(FOCUS_MODULE) to run again with no focus request → focusedModule=255 → main screen.
return INACTIVATE_AFTER_MS;
}
}
switch (this->payload) { switch (this->payload) {
case INPUT_BROKER_LEFT: case INPUT_BROKER_LEFT:
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor > 0) { if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor > 0) {
@@ -1247,6 +1339,7 @@ int32_t CannedMessageModule::runOnce()
} }
} }
this->lastTouchMillis = millis(); this->lastTouchMillis = millis();
this->payload = 0; // Clear payload after processing to prevent re-processing
this->notifyObservers(&e); this->notifyObservers(&e);
return INACTIVATE_AFTER_MS; return INACTIVATE_AFTER_MS;
} }
@@ -1877,7 +1970,20 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
display->setColor(WHITE); display->setColor(WHITE);
{ {
int inputY = 0 + y + FONT_HEIGHT_SMALL; int inputY = 0 + y + FONT_HEIGHT_SMALL;
String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor);
// If there's a pending multi-tap character, show it at cursor as preview
String displayText = this->freetext;
unsigned int displayCursor = this->cursor;
if (this->multiTapKey != 0xFF && this->multiTapIndex >= 0) {
const char *ch = t9LetterMap[this->multiTapKey][this->multiTapIndex];
if (ch) {
char c = (this->inputMode == InputMode::UPPER) ? toupper(*ch) : *ch;
displayText = displayText.substring(0, this->cursor) + String(c) + displayText.substring(this->cursor);
displayCursor = this->cursor + 1; // cursor should be AFTER the preview char
}
}
String msgWithCursor = this->drawWithCursor(displayText, displayCursor);
// Tokenize input into (isEmote, token) pairs // Tokenize input into (isEmote, token) pairs
std::vector<std::pair<bool, String>> tokens; std::vector<std::pair<bool, String>> tokens;
@@ -2014,6 +2120,21 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
} }
yLine += rowHeight; yLine += rowHeight;
} }
// --- Input mode indicator (right-aligned, bottom of screen) ---
{
const char *modeLabel;
switch (inputMode) {
case InputMode::DIGIT: modeLabel = "123"; break;
case InputMode::LOWER: modeLabel = "abc"; break;
case InputMode::UPPER: modeLabel = "ABC"; break;
default: modeLabel = "?"; break;
}
display->setTextAlignment(TEXT_ALIGN_RIGHT);
int modeY = y + display->getHeight() - FONT_HEIGHT_SMALL;
display->drawString(x + display->getWidth(), modeY, modeLabel);
display->setTextAlignment(TEXT_ALIGN_LEFT); // restore default
}
} }
#endif #endif
return; return;
@@ -2327,4 +2448,50 @@ String CannedMessageModule::drawWithCursor(String text, int cursor)
return result; return result;
} }
// === T9 Multi-tap helper methods ===
bool CannedMessageModule::commitMultiTap()
{
if (multiTapKey == 0xFF)
return false;
const char *ch = t9LetterMap[multiTapKey][multiTapIndex];
if (!ch)
return false;
// In UPPER/LOWER mode, index 0+ are letters from t9LetterMap; apply case
char c = (inputMode == InputMode::UPPER) ? toupper(*ch) : *ch;
payload = c;
committingMultiTap = true; // Guard against reentrant call from runOnce
runOnce();
// runOnce() already triggers notifyObservers(REGENERATE_FRAMESET) + screen refresh,
// so we must NOT call it again here. A second REGENERATE_FRAMESET would cause
// Screen::setFrames(FOCUS_MODULE) to be called again, but _requestingFocus was
// already consumed by the first call → focusedModule=255 → jumps to main screen.
payload = 0; // Clear payload to prevent re-processing by next scheduled runOnce
committingMultiTap = false;
multiTapKey = 0xFF;
multiTapIndex = 0;
multiTapLastMs = 0;
return true;
}
void CannedMessageModule::showMultiTapPreview()
{
if (multiTapKey == 0xFF)
return;
// Use REDRAW_ONLY instead of REGENERATE_FRAMESET. REGENERATE_FRAMESET triggers
// Screen::setFrames(FOCUS_MODULE) which calls isRequestingFocus() — but the focus
// request was already consumed by a previous call → focusedModule=255 → jumps to
// main screen. REDRAW_ONLY just forces a redraw of the current frame.
UIFrameEvent e;
e.action = UIFrameEvent::Action::REDRAW_ONLY;
notifyObservers(&e);
screen->forceDisplay();
}
#endif #endif
@@ -192,6 +192,16 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
int charSet = 0; // 0=ABC, 1=123 int charSet = 0; // 0=ABC, 1=123
#endif #endif
// === Multi-tap T9 input method (for TCA9535 numpad) ===
enum class InputMode : uint8_t { DIGIT, LOWER, UPPER };
InputMode inputMode = InputMode::LOWER; // 默认小写字母模式
uint8_t multiTapKey = 0xFF; // 上一次按的数字键 ('0'-'9'), 0xFF = no pending key
uint8_t multiTapIndex = 0; // 当前循环索引
uint32_t multiTapLastMs = 0; // 上一次按键时间戳
bool committingMultiTap = false; // 重入保护(commitMultiTap → runOnce → commitMultiTap
static constexpr uint32_t MULTI_TAP_TIMEOUT_MS = 800; // multi-tap 超时(ms
static const char *const t9LetterMap[][5]; // T9 字母模式映射表(UPPER/LOWER 模式用此表)
bool isUpEvent(const InputEvent *event); bool isUpEvent(const InputEvent *event);
bool isDownEvent(const InputEvent *event); bool isDownEvent(const InputEvent *event);
bool isSelectEvent(const InputEvent *event); bool isSelectEvent(const InputEvent *event);
@@ -199,6 +209,8 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect); int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect); bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
bool handleFreeTextInput(const InputEvent *event); bool handleFreeTextInput(const InputEvent *event);
bool commitMultiTap(); // Returns true if a character was actually committed
void showMultiTapPreview();
#if defined(USE_VIRTUAL_KEYBOARD) #if defined(USE_VIRTUAL_KEYBOARD)
Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0}, Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0},