Files
lora_meshtastic_project/code/firmware-2.7.15.567b8ea/src/modules/CannedMessageModule.cpp
T

2714 lines
103 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "configuration.h"
#if ARCH_PORTDUINO
#include "PortduinoGlue.h"
#endif
#if HAS_SCREEN
#include "CannedMessageModule.h"
#include "Channels.h"
#include "FSCommon.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "SPILock.h"
#include "buzz.h"
#include "detect/ScanI2C.h"
#include "graphics/Screen.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/emotes.h"
#include "graphics/images.h"
#include "main.h" // for cardkb_found
#include "mesh/generated/meshtastic/cannedmessages.pb.h"
#include "modules/AdminModule.h"
#include "modules/ExternalNotificationModule.h" // for buzzer control
#if HAS_TRACKBALL
#include "input/TrackballInterruptImpl1.h"
#endif
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
#include "graphics/EInkDynamicDisplay.h" // To select between full and fast refresh on E-Ink displays
#endif
#ifndef INPUTBROKER_MATRIX_TYPE
#define INPUTBROKER_MATRIX_TYPE 0
#endif
#include "graphics/ScreenFonts.h"
#include "graphics/fonts/ChineseFont12x12.h"
#include <Throttle.h>
// === 中文输入法:常用汉字列表(6个)===
// 在 handleChineseInput() 和 drawChineseInput() 方法中硬编码使用
// 格式:数字键 -> 汉字
static const char* chineseChars[] = {"我", "你", "他", "是", "的", "好"};
// Remove Canned message screen if no action is taken for some milliseconds
#define INACTIVATE_AFTER_MS 20000
extern ScanI2C::DeviceAddress cardkb_found;
extern bool graphics::isMuted;
extern bool osk_found;
static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto";
static NodeNum lastDest = NODENUM_BROADCAST;
static uint8_t lastChannel = 0;
static bool lastDestSet = false;
meshtastic_CannedMessageModuleConfig cannedMessageModuleConfig;
CannedMessageModule *cannedMessageModule;
CannedMessageModule::CannedMessageModule()
: SinglePortModule("canned", meshtastic_PortNum_TEXT_MESSAGE_APP), concurrency::OSThread("CannedMessage")
{
this->loadProtoForModule();
if ((this->splitConfiguredMessages() <= 0) && (cardkb_found.address == 0x00) && !INPUTBROKER_MATRIX_TYPE &&
!CANNED_MESSAGE_MODULE_ENABLE) {
LOG_INFO("CannedMessageModule: No messages are configured. Module is disabled");
this->runState = CANNED_MESSAGE_RUN_STATE_DISABLED;
disable();
} else {
LOG_INFO("CannedMessageModule is enabled");
moduleConfig.canned_message.enabled = true;
this->inputObserver.observe(inputBroker);
}
}
void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChannel)
{
// Use the requested destination, unless it's "broadcast" and we have a previous node/channel
if (newDest == NODENUM_BROADCAST && lastDestSet) {
newDest = lastDest;
newChannel = lastChannel;
}
dest = newDest;
channel = newChannel;
lastDest = dest;
lastChannel = channel;
lastDestSet = true;
// Rest of function unchanged...
// Upon activation, highlight "[Select Destination]"
int selectDestination = 0;
for (int i = 0; i < messagesCount; ++i) {
if (strcmp(messages[i], "[Select Destination]") == 0) {
selectDestination = i;
break;
}
}
currentMessageIndex = selectDestination;
// This triggers the canned message list
runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
}
void CannedMessageModule::LaunchRepeatDestination()
{
if (!lastDestSet) {
LaunchWithDestination(NODENUM_BROADCAST, 0);
} else {
LaunchWithDestination(lastDest, lastChannel);
}
}
void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t newChannel)
{
// Use the requested destination, unless it's "broadcast" and we have a previous node/channel
if (newDest == NODENUM_BROADCAST && lastDestSet) {
newDest = lastDest;
newChannel = lastChannel;
}
dest = newDest;
channel = newChannel;
lastDest = dest;
lastChannel = channel;
lastDestSet = true;
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
}
static bool returnToCannedList = false;
bool hasKeyForNode(const meshtastic_NodeInfoLite *node)
{
return node && node->has_user && node->user.public_key.size > 0;
}
/**
* @brief Items in array this->messages will be set to be pointing on the right
* starting points of the string this->messageStore
*
* @return int Returns the number of messages found.
*/
int CannedMessageModule::splitConfiguredMessages()
{
int i = 0;
String canned_messages = cannedMessageModuleConfig.messages;
// Copy all message parts into the buffer
strncpy(this->messageStore, canned_messages.c_str(), sizeof(this->messageStore));
// Temporary array to allow for insertion
const char *tempMessages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT + 3] = {0};
int tempCount = 0;
// Insert at position 0 (top)
tempMessages[tempCount++] = "[Select Destination]";
#if defined(USE_VIRTUAL_KEYBOARD)
// Add a "Free Text" entry at the top if using a touch screen virtual keyboard
tempMessages[tempCount++] = "[-- Free Text --]";
#else
if (osk_found && screen) {
tempMessages[tempCount++] = "[-- Free Text --]";
}
#endif
// First message always starts at buffer start
tempMessages[tempCount++] = this->messageStore;
int upTo = strlen(this->messageStore) - 1;
// Walk buffer, splitting on '|'
while (i < upTo) {
if (this->messageStore[i] == '|') {
this->messageStore[i] = '\0'; // End previous message
if (tempCount >= CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT)
break;
tempMessages[tempCount++] = (this->messageStore + i + 1);
}
i += 1;
}
// Add [Exit] as the last entry
tempMessages[tempCount++] = "[Exit]";
// Copy to the member array
for (int k = 0; k < tempCount; ++k) {
this->messages[k] = (char *)tempMessages[k];
}
this->messagesCount = tempCount;
return this->messagesCount;
}
void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer)
{
if (graphics::isHighResolution) {
if (this->dest == NODENUM_BROADCAST) {
display->drawStringf(x, y, buffer, "To: Broadcast@%s", channels.getName(this->channel));
} else {
display->drawStringf(x, y, buffer, "To: %s", getNodeName(this->dest));
}
} else {
if (this->dest == NODENUM_BROADCAST) {
display->drawStringf(x, y, buffer, "To: Broadc@%.5s", channels.getName(this->channel));
} else {
display->drawStringf(x, y, buffer, "To: %s", getNodeName(this->dest));
}
}
}
void CannedMessageModule::resetSearch()
{
LOG_INFO("Resetting search, restoring full destination list");
int previousDestIndex = destIndex;
searchQuery = "";
updateDestinationSelectionList();
// Adjust scrollIndex so previousDestIndex is still visible
int totalEntries = activeChannelIndices.size() + filteredNodes.size();
this->visibleRows = (displayHeight - FONT_HEIGHT_SMALL * 2) / FONT_HEIGHT_SMALL;
if (this->visibleRows < 1)
this->visibleRows = 1;
int maxScrollIndex = std::max(0, totalEntries - visibleRows);
scrollIndex = std::min(std::max(previousDestIndex - (visibleRows / 2), 0), maxScrollIndex);
lastUpdateMillis = millis();
requestFocus();
}
void CannedMessageModule::updateDestinationSelectionList()
{
static size_t lastNumMeshNodes = 0;
static String lastSearchQuery = "";
size_t numMeshNodes = nodeDB->getNumMeshNodes();
bool nodesChanged = (numMeshNodes != lastNumMeshNodes);
lastNumMeshNodes = numMeshNodes;
// Early exit if nothing changed
if (searchQuery == lastSearchQuery && !nodesChanged)
return;
lastSearchQuery = searchQuery;
needsUpdate = false;
this->filteredNodes.clear();
this->activeChannelIndices.clear();
NodeNum myNodeNum = nodeDB->getNodeNum();
String lowerSearchQuery = searchQuery;
lowerSearchQuery.toLowerCase();
// Preallocate space to reduce reallocation
this->filteredNodes.reserve(numMeshNodes);
for (size_t i = 0; i < numMeshNodes; ++i) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
if (!node || node->num == myNodeNum || !node->has_user || node->user.public_key.size != 32)
continue;
const String &nodeName = node->user.long_name;
if (searchQuery.length() == 0) {
this->filteredNodes.push_back({node, sinceLastSeen(node)});
} else {
// Avoid unnecessary lowercase conversion if already matched
String lowerNodeName = nodeName;
lowerNodeName.toLowerCase();
if (lowerNodeName.indexOf(lowerSearchQuery) != -1) {
this->filteredNodes.push_back({node, sinceLastSeen(node)});
}
}
}
// Populate active channels
std::vector<String> seenChannels;
seenChannels.reserve(channels.getNumChannels());
for (uint8_t i = 0; i < channels.getNumChannels(); ++i) {
String name = channels.getName(i);
if (name.length() > 0 && std::find(seenChannels.begin(), seenChannels.end(), name) == seenChannels.end()) {
this->activeChannelIndices.push_back(i);
seenChannels.push_back(name);
}
}
/* As the nodeDB is sorted, can skip this step
// Sort by favorite, then last heard
std::sort(this->filteredNodes.begin(), this->filteredNodes.end(), [](const NodeEntry &a, const NodeEntry &b) {
if (a.node->is_favorite != b.node->is_favorite)
return a.node->is_favorite > b.node->is_favorite;
return a.lastHeard < b.lastHeard;
});
*/
scrollIndex = 0; // Show first result at the top
destIndex = 0; // Highlight the first entry
if (nodesChanged && runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
LOG_INFO("Nodes changed, forcing UI refresh.");
screen->forceDisplay();
}
}
// Returns true if character input is currently allowed (used for search/freetext states)
bool CannedMessageModule::isCharInputAllowed() const
{
return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
}
/**
* Main input event dispatcher for CannedMessageModule.
* Routes keyboard/button/touch input to the correct handler based on the current runState.
* Only one handler (per state) processes each event, eliminating redundancy.
*/
int CannedMessageModule::handleInputEvent(const InputEvent *event)
{
LOG_DEBUG("CannedMessage: handleInputEvent called, runState=%d, inputEvent=%d, kbchar=%d",
runState, event->inputEvent, event->kbchar);
// Block ALL input if an alert banner is active
if (screen && screen->isOverlayBannerShowing()) {
return 0;
}
// Tab key: Always allow switching between canned/destination screens
if (event->kbchar == INPUT_BROKER_MSG_TAB && handleTabSwitch(event))
return 1;
// Matrix keypad: If matrix key with printable char, let it fall through to
// the normal input path (INACTIVE→FREETEXT or FREETEXT→append char).
// Only intercept as canned-message selector if kbchar is a valid 1-based index
// (e.g., RAK14004 sends kbchar=1..16).
if (event->inputEvent == INPUT_BROKER_MATRIXKEY) {
// Printable ASCII (32-126): treat as keyboard input, don't intercept
if (event->kbchar >= 32 && event->kbchar <= 126) {
// Fall through to normal input handling below
} else {
// 1-based index from hardware like RAK14004
int idx = event->kbchar - 1;
if (idx < 0 || idx >= messagesCount) {
return 0; // kbchar out of range, ignore
}
runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT;
payload = INPUT_BROKER_MATRIXKEY;
currentMessageIndex = idx;
lastTouchMillis = millis();
requestFocus();
return 1;
}
}
// Always normalize navigation/select buttons for further handlers
bool isUp = isUpEvent(event);
bool isDown = isDownEvent(event);
bool isSelect = isSelectEvent(event);
// Route event to handler for current UI state (no double-handling)
switch (runState) {
// Node/Channel destination selection mode: Handles character search, arrows, select, cancel, backspace
case CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION:
if (handleDestinationSelectionInput(event, isUp, isDown, isSelect))
return 1;
return 0; // prevent fall-through to selector input
// Free text input mode: Handles character input, cancel, backspace, select, etc.
case CANNED_MESSAGE_RUN_STATE_FREETEXT:
return handleFreeTextInput(event); // All allowed input for this state
// Virtual keyboard mode: Show virtual keyboard and handle input
// If sending, block all input except global/system (handled above)
case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE:
return 1;
// Canned message list: LEFT/RIGHT enters free text input
case CANNED_MESSAGE_RUN_STATE_ACTIVE:
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) {
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
textScrollOffset = 0; // 重置滚动偏移
cursor = 0;
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
return 1;
}
break;
// If sending, block all input except global/system (handled above)
case CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER:
return handleEmotePickerInput(event);
case CANNED_MESSAGE_RUN_STATE_INACTIVE:
LOG_DEBUG("CannedMessage: INACTIVE state, inputEvent=%d, kbchar=%d", event->inputEvent, event->kbchar);
if (isSelect) {
return 0; // Main button press no longer runs through powerFSM
}
// Let LEFT/RIGHT pass through so frame navigation works
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) {
break;
}
// 按 * 键进入自由文本输入模式(FREETEXT)
// Printable char (ASCII) opens free text compose
if (event->kbchar >= 32 && event->kbchar <= 126) {
LOG_DEBUG("CannedMessage: Entering FREETEXT, kbchar=%d", event->kbchar);
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
textScrollOffset = 0; // 重置滚动偏移
cursor = 0;
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
// 只切换状态,不处理当前按键(让用户下次按键时输入字符)
return 1;
}
break;
// (Other states can be added here as needed)
default:
break;
}
// If no state handler above processed the event, let the message selector try to handle it
// (Handles up/down/select on canned message list, exit/return)
if (handleMessageSelectorInput(event, isUp, isDown, isSelect))
return 1;
// Default: event not handled by canned message system, allow others to process
return 0;
}
bool CannedMessageModule::isUpEvent(const InputEvent *event)
{
return event->inputEvent == INPUT_BROKER_UP ||
((runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER ||
runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) &&
(event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_ALT_PRESS));
}
bool CannedMessageModule::isDownEvent(const InputEvent *event)
{
return event->inputEvent == INPUT_BROKER_DOWN ||
((runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER ||
runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) &&
(event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS));
}
bool CannedMessageModule::isSelectEvent(const InputEvent *event)
{
return event->inputEvent == INPUT_BROKER_SELECT;
}
bool CannedMessageModule::handleTabSwitch(const InputEvent *event)
{
if (event->kbchar != 0x09)
return false;
runState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
destIndex = 0;
scrollIndex = 0;
// RESTORE THIS!
if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
updateDestinationSelectionList();
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
int CannedMessageModule::handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect)
{
// Override isDown and isSelect ONLY for destination selector behavior
if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
if (event->inputEvent == INPUT_BROKER_USER_PRESS) {
isDown = true;
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
isSelect = true;
}
}
if (event->kbchar >= 32 && event->kbchar <= 126 && !isUp && !isDown && event->inputEvent != INPUT_BROKER_LEFT &&
event->inputEvent != INPUT_BROKER_RIGHT && event->inputEvent != INPUT_BROKER_SELECT) {
this->searchQuery += (char)event->kbchar;
needsUpdate = true;
if ((millis() - lastFilterUpdate) > filterDebounceMs) {
runOnce(); // update filter immediately
lastFilterUpdate = millis();
}
return 1;
}
size_t numMeshNodes = filteredNodes.size();
int totalEntries = numMeshNodes + activeChannelIndices.size();
int columns = 1;
int totalRows = totalEntries;
int maxScrollIndex = std::max(0, totalRows - visibleRows);
scrollIndex = clamp(scrollIndex, 0, maxScrollIndex);
// Handle backspace
if (event->inputEvent == INPUT_BROKER_BACK) {
if (searchQuery.length() > 0) {
searchQuery.remove(searchQuery.length() - 1);
needsUpdate = true;
runOnce();
}
if (searchQuery.length() == 0) {
resetSearch();
needsUpdate = false;
}
return 1;
}
if (isUp) {
if (destIndex > 0) {
destIndex--;
} else if (totalEntries > 0) {
destIndex = totalEntries - 1;
}
if ((destIndex / columns) < scrollIndex)
scrollIndex = destIndex / columns;
else if ((destIndex / columns) >= (scrollIndex + visibleRows))
scrollIndex = (destIndex / columns) - visibleRows + 1;
screen->forceDisplay(true);
return 1;
}
if (isDown) {
if (destIndex + 1 < totalEntries) {
destIndex++;
} else if (totalEntries > 0) {
destIndex = 0;
scrollIndex = 0;
}
if ((destIndex / columns) >= (scrollIndex + visibleRows))
scrollIndex = (destIndex / columns) - visibleRows + 1;
screen->forceDisplay(true);
return 1;
}
// SELECT
if (isSelect) {
if (destIndex < static_cast<int>(activeChannelIndices.size())) {
dest = NODENUM_BROADCAST;
channel = activeChannelIndices[destIndex];
lastDest = dest;
lastChannel = channel;
lastDestSet = true;
} else {
int nodeIndex = destIndex - static_cast<int>(activeChannelIndices.size());
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(filteredNodes.size())) {
const meshtastic_NodeInfoLite *selectedNode = filteredNodes[nodeIndex].node;
if (selectedNode) {
dest = selectedNode->num;
channel = selectedNode->channel;
// Already saves here, but for clarity, also:
lastDest = dest;
lastChannel = channel;
lastDestSet = true;
}
}
}
runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT;
returnToCannedList = false;
screen->forceDisplay(true);
return 1;
}
// CANCEL
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) {
runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT;
returnToCannedList = false;
searchQuery = "";
// UIFrameEvent e;
// e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
// notifyObservers(&e);
screen->forceDisplay(true);
return 1;
}
return 0;
}
bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect)
{
// INACTIVE 状态下不处理 UP/DOWN(由 * 键触发自由文本输入)
if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE) {
return false;
}
// Override isDown and isSelect ONLY for canned message list behavior
if (runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) {
if (event->inputEvent == INPUT_BROKER_USER_PRESS) {
isDown = true;
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
isSelect = true;
}
}
if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
return false;
// === Handle Cancel key: go inactive, clear UI state ===
if (runState != CANNED_MESSAGE_RUN_STATE_INACTIVE &&
(event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG)) {
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
freetext = "";
cursor = 0;
payload = 0;
currentMessageIndex = -1;
// Notify UI that we want to redraw/close this screen
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
bool handled = false;
// Handle up/down navigation
if (isUp && messagesCount > 0) {
runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP;
handled = true;
} else if (isDown && messagesCount > 0) {
runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN;
handled = true;
} else if (isSelect) {
const char *current = messages[currentMessageIndex];
// === [Select Destination] triggers destination selection UI ===
if (strcmp(current, "[Select Destination]") == 0) {
returnToCannedList = true;
runState = CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
destIndex = 0;
scrollIndex = 0;
updateDestinationSelectionList(); // Make sure list is fresh
screen->forceDisplay();
return true;
}
// === [Exit] returns to the main/inactive screen ===
if (strcmp(current, "[Exit]") == 0) {
// Set runState to inactive so we return to main UI
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
currentMessageIndex = -1;
// Notify UI to regenerate frame set and redraw
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
// === [Free Text] triggers the free text input (virtual keyboard) ===
#if defined(USE_VIRTUAL_KEYBOARD)
if (strcmp(current, "[-- Free Text --]") == 0) {
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
requestFocus();
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
return true;
}
#else
if (strcmp(current, "[-- Free Text --]") == 0) {
if (osk_found && screen) {
char headerBuffer[64];
if (this->dest == NODENUM_BROADCAST) {
snprintf(headerBuffer, sizeof(headerBuffer), "To: Broadcast@%s", channels.getName(this->channel));
} else {
snprintf(headerBuffer, sizeof(headerBuffer), "To: %s", getNodeName(this->dest));
}
screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) {
if (!text.empty()) {
this->freetext = text.c_str();
this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
currentMessageIndex = -1;
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->notifyObservers(&e);
screen->forceDisplay();
setIntervalFromNow(500);
return;
} else {
// Don't delete virtual keyboard immediately - it might still be executing
// Instead, just clear the callback and reset banner to stop input processing
graphics::NotificationRenderer::textInputCallback = nullptr;
graphics::NotificationRenderer::resetBanner();
// Return to inactive state
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
// Force display update to show normal screen
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->notifyObservers(&e);
screen->forceDisplay();
// Schedule cleanup for next loop iteration to ensure safe deletion
setIntervalFromNow(50);
return;
}
});
return true;
}
}
#endif
// Normal canned message selection
if (runState == CANNED_MESSAGE_RUN_STATE_INACTIVE || runState == CANNED_MESSAGE_RUN_STATE_DISABLED) {
} else {
#if CANNED_MESSAGE_ADD_CONFIRMATION
// Show confirmation dialog before sending canned message
NodeNum destNode = dest;
ChannelIndex chan = channel;
graphics::menuHandler::showConfirmationBanner("Send message?", [this, destNode, chan, current]() {
this->sendText(destNode, chan, current, false);
payload = runState;
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
currentMessageIndex = -1;
// Notify UI to regenerate frame set and redraw
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
});
#else
payload = runState;
runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT;
#endif
// Do not immediately set runState; wait for confirmation
handled = true;
}
}
if (handled) {
requestFocus();
if (runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT)
setIntervalFromNow(0);
else
runOnce();
}
return handled;
}
bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
{
// Always process only if in FREETEXT mode
if (runState != CANNED_MESSAGE_RUN_STATE_FREETEXT)
return false;
// === 中文输入模式处理 ===
if (inputMode == InputMode::CHINESE) {
return handleChineseInput(event);
}
#if defined(USE_VIRTUAL_KEYBOARD)
// Cancel (dismiss freetext screen)
if (event->inputEvent == INPUT_BROKER_LEFT) {
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
freetext = "";
cursor = 0;
payload = 0;
currentMessageIndex = -1;
// Notify UI that we want to redraw/close this screen
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
// Touch input (virtual keyboard) handling
// Only handle if touch coordinates present (CardKB won't set these)
if (event->touchX != 0 || event->touchY != 0) {
String keyTapped = keyForCoordinates(event->touchX, event->touchY);
bool valid = false;
if (keyTapped == "⇧") {
highlight = -1;
payload = 0x00;
shift = !shift;
valid = true;
} else if (keyTapped == "⌫") {
#ifndef RAK14014
highlight = keyTapped[0];
#endif
payload = 0x08;
shift = false;
valid = true;
} else if (keyTapped == "123" || keyTapped == "ABC") {
highlight = -1;
payload = 0x00;
charSet = (charSet == 0 ? 1 : 0);
valid = true;
} else if (keyTapped == " ") {
#ifndef RAK14014
highlight = keyTapped[0];
#endif
payload = keyTapped[0];
shift = false;
valid = true;
}
// Touch enter/submit
else if (keyTapped == "↵") {
runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; // Send the message!
payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
currentMessageIndex = -1;
shift = false;
valid = true;
} else if (!(keyTapped == "")) {
#ifndef RAK14014
highlight = keyTapped[0];
#endif
payload = shift ? keyTapped[0] : std::tolower(keyTapped[0]);
shift = false;
valid = true;
}
if (valid) {
lastTouchMillis = millis();
runOnce();
payload = 0;
return true; // STOP: We handled a VKB touch
}
}
#endif // USE_VIRTUAL_KEYBOARD
// ---- All hardware keys fall through to here (CardKB, physical, etc.) ----
if (event->kbchar == INPUT_BROKER_MSG_EMOTE_LIST) {
runState = CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER;
requestFocus();
screen->forceDisplay();
return true;
}
// Confirm select (Enter)
bool isSelect = isSelectEvent(event);
if (isSelect) {
commitMultiTap();
LOG_DEBUG("[SELECT] handleFreeTextInput: runState=%d, dest=%u, channel=%d, freetext='%s'", (int)runState, dest, channel,
freetext.c_str());
if (dest == 0)
dest = NODENUM_BROADCAST;
// Defensive: If channel isn't valid, pick the first available channel
if (channel >= channels.getNumChannels())
channel = 0;
payload = CANNED_MESSAGE_RUN_STATE_FREETEXT;
currentMessageIndex = -1;
runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT;
lastTouchMillis = millis();
runOnce();
return true;
}
// Backspace
if (event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() > 0) {
payload = 0x08;
lastTouchMillis = millis();
runOnce();
payload = 0;
return true;
}
// LEFT/RIGHT in FREETEXT: go back to canned message list (ACTIVE), preserving input
if (event->inputEvent == INPUT_BROKER_LEFT || event->inputEvent == INPUT_BROKER_RIGHT) {
commitMultiTap();
runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
requestFocus(); // commitMultiTap's runOnce() consumed the previous focus request
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
// Cancel (dismiss freetext screen)
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG ||
(event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() == 0)) {
multiTapKey = 0xFF; // discard pending multi-tap
runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
freetext = "";
cursor = 0;
payload = 0;
currentMessageIndex = -1;
// Notify UI that we want to redraw/close this screen
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
notifyObservers(&e);
screen->forceDisplay();
return true;
}
// Tab (switch destination)
if (event->kbchar == INPUT_BROKER_MSG_TAB) {
return handleTabSwitch(event); // Reuse tab logic
}
// '*' key from TCA9535 numpad acts as backspace (always, even if empty)
if (event->kbchar == '*') {
payload = 0x08;
lastTouchMillis = millis();
runOnce();
payload = 0;
return true;
}
// UP/DOWN keys: move cursor in FREETEXT mode
if (event->inputEvent == INPUT_BROKER_UP) {
if (cursor > 0) {
cursor--;
// 确保光标在可见区域内,自动滚动
if (cursor < textScrollOffset) {
textScrollOffset = cursor;
}
}
lastTouchMillis = millis();
screen->forceDisplay();
return true;
}
if (event->inputEvent == INPUT_BROKER_DOWN) {
if (cursor < freetext.length()) {
cursor++;
}
lastTouchMillis = millis();
screen->forceDisplay();
return true;
}
// '#' key: cycle input mode DIGIT -> LOWER -> UPPER -> CHINESE -> DIGIT ...
if (event->kbchar == '#') {
// First, commit any pending multi-tap character
commitMultiTap();
int m = static_cast<int>(inputMode);
inputMode = static_cast<InputMode>((m + 1) % 4); // 修改为 %4,支持4种模式
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) {
commitMultiTap();
payload = event->kbchar;
lastTouchMillis = millis();
runOnce();
payload = 0;
return true;
}
return false;
}
int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
{
int numEmotes = graphics::numEmotes;
// Override isDown and isSelect ONLY for emote picker behavior
bool isUp = isUpEvent(event);
bool isDown = isDownEvent(event);
bool isSelect = isSelectEvent(event);
if (runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) {
if (event->inputEvent == INPUT_BROKER_USER_PRESS) {
isDown = true;
} else if (event->inputEvent == INPUT_BROKER_SELECT) {
isSelect = true;
}
}
// Scroll emote list
if (isUp && emotePickerIndex > 0) {
emotePickerIndex--;
screen->forceDisplay();
return 1;
}
if (isDown && emotePickerIndex < numEmotes - 1) {
emotePickerIndex++;
screen->forceDisplay();
return 1;
}
// Select emote: insert into freetext at cursor and return to freetext
if (isSelect) {
String label = graphics::emotes[emotePickerIndex].label;
String emoteInsert = label; // Just the text label, e.g., ":thumbsup:"
if (cursor == freetext.length()) {
freetext += emoteInsert;
} else {
freetext = freetext.substring(0, cursor) + emoteInsert + freetext.substring(cursor);
}
cursor += emoteInsert.length();
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
screen->forceDisplay();
return 1;
}
// Cancel returns to freetext
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) {
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
screen->forceDisplay();
return 1;
}
return 0;
}
// === 中文输入法:数字选择汉字 ===
bool CannedMessageModule::handleChineseInput(const InputEvent *event)
{
// 数字键1-6:选择对应的汉字
if (event->kbchar >= '1' && event->kbchar <= '6') {
// 常用汉字列表(6个)
static const char* chineseChars[] = {"我", "你", "他", "是", "的", "好"};
int index = event->kbchar - '1';
// 插入选中的汉字到 freetext
String chineseChar = chineseChars[index];
if (cursor >= freetext.length()) {
freetext += chineseChar;
} else {
freetext = freetext.substring(0, cursor) + chineseChar + freetext.substring(cursor);
}
cursor += chineseChar.length();
lastTouchMillis = millis();
screen->forceDisplay();
return true;
}
// * 键或 BACK 键:删除最后一个字符
if (event->kbchar == '*' || event->inputEvent == INPUT_BROKER_BACK) {
if (freetext.length() > 0) {
// 简化处理:删除最后1-3个字节(UTF-8中文是3字节)
// 找到最后一个UTF-8字符的起始位置
int lastPos = freetext.length() - 1;
while (lastPos >= 0 && (freetext[lastPos] & 0xC0) == 0x80) {
lastPos--; // 向前找到UTF-8字符的起始字节
}
if (lastPos >= 0) {
freetext.remove(lastPos);
if (cursor > lastPos) {
cursor = lastPos;
}
}
}
lastTouchMillis = millis();
screen->forceDisplay();
return true;
}
// SELECT 键:切换回其他输入模式(或确认输入)
if (isSelectEvent(event)) {
// 切换到小写字母模式,继续输入
inputMode = InputMode::LOWER;
lastTouchMillis = millis();
screen->forceDisplay();
return true;
}
// 其他按键:不处理
return false;
}
// === 绘制中文输入界面 ===
void CannedMessageModule::drawChineseInput(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
// 常用汉字列表(6个)
static const char* chineseChars[] = {"我", "你", "他", "是", "的", "好"};
// 设置字体(使用支持中文的字体)
display->setFont(FONT_MEDIUM);
display->setTextAlignment(TEXT_ALIGN_LEFT);
// 绘制标题
display->drawString(0, 0, "中文输入:");
// 绘制已输入的文本
if (freetext.length() > 0) {
display->drawString(0, 16, "文本:" + freetext);
}
// 绘制候选汉字(在屏幕底部)
int startY = display->getHeight() - 16; // 从底部向上绘制
int xPos = 0;
for (int i = 0; i < 6; i++) {
String label = String(i+1) + ":" + String(chineseChars[i]);
display->drawString(xPos, startY, label);
xPos += 40; // 每个候选字间隔40像素
if (xPos > display->getWidth() - 40) {
xPos = 0;
startY -= 16; // 换行
}
}
}
void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies)
{
lastDest = dest;
lastChannel = channel;
lastDestSet = true;
// === Prepare packet ===
meshtastic_MeshPacket *p = allocDataPacket();
p->to = dest;
p->channel = channel;
p->want_ack = true;
// Save destination for ACK/NACK UI fallback
this->lastSentNode = dest;
this->incoming = dest;
// Copy message payload
p->decoded.payload.size = strlen(message);
memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
// Optionally add bell character
if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
p->decoded.payload.bytes[p->decoded.payload.size++] = 7; // Bell
p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; // Null-terminate
}
// Mark as waiting for ACK to trigger ACK/NACK screen
this->waitingForAck = true;
// Log outgoing message
LOG_INFO("Send message id=%u, dest=%x, msg=%.*s", p->id, p->to, p->decoded.payload.size, p->decoded.payload.bytes);
if (p->to != 0xffffffff) {
// Only add as favorite if our role is NOT CLIENT_BASE
if (config.device.role != 12) {
LOG_INFO("Proactively adding %x as favorite node", p->to);
nodeDB->set_favorite(true, p->to);
} else {
LOG_DEBUG("Not favoriting node %x as we are CLIENT_BASE role", p->to);
}
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
p->pki_encrypted = true;
p->channel = 0;
}
// Send to mesh and phone (even if no phone connected, to track ACKs)
service->sendToMesh(p, RX_SRC_LOCAL, true);
// === Simulate local message to clear unread UI ===
if (screen) {
meshtastic_MeshPacket simulatedPacket = {};
simulatedPacket.from = 0; // Local device
screen->handleTextMessage(&simulatedPacket);
}
playComboTune();
}
int32_t CannedMessageModule::runOnce()
{
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) {
updateDestinationSelectionList();
needsUpdate = false;
}
// If we're in node selection, do nothing except keep alive
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
return INACTIVATE_AFTER_MS;
}
// Normal module disable/idle handling
if ((this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) {
// Clean up virtual keyboard if needed when going inactive
if (graphics::NotificationRenderer::virtualKeyboard && graphics::NotificationRenderer::textInputCallback == nullptr) {
LOG_INFO("Performing delayed virtual keyboard cleanup");
delete graphics::NotificationRenderer::virtualKeyboard;
graphics::NotificationRenderer::virtualKeyboard = nullptr;
}
temporaryMessage = "";
return INT32_MAX;
}
// Handle delayed virtual keyboard message sending
if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
// Virtual keyboard message sending case - text was not empty
if (this->freetext.length() > 0) {
LOG_INFO("Processing delayed virtual keyboard send: '%s'", this->freetext.c_str());
sendText(this->dest, this->channel, this->freetext.c_str(), true);
// Clean up virtual keyboard after sending
if (graphics::NotificationRenderer::virtualKeyboard) {
LOG_INFO("Cleaning up virtual keyboard after message send");
delete graphics::NotificationRenderer::virtualKeyboard;
graphics::NotificationRenderer::virtualKeyboard = nullptr;
graphics::NotificationRenderer::textInputCallback = nullptr;
graphics::NotificationRenderer::resetBanner();
}
// Clear payload to indicate virtual keyboard processing is complete
// But keep SENDING_ACTIVE state to show "Sending..." screen for 2 seconds
this->payload = 0;
} else {
// Empty message, just go inactive
LOG_INFO("Empty freetext detected in delayed processing, returning to inactive state");
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
}
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
this->notifyObservers(&e);
return 2000;
}
UIFrameEvent e;
if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload != 0 &&
this->payload != CANNED_MESSAGE_RUN_STATE_FREETEXT) ||
(this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) ||
(this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) {
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
temporaryMessage = "";
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
this->notifyObservers(&e);
}
// Handle SENDING_ACTIVE state transition after virtual keyboard message
else if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == 0) {
// This happens after virtual keyboard message sending is complete
LOG_INFO("Virtual keyboard message sending completed, returning to inactive state");
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
temporaryMessage = "";
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
this->notifyObservers(&e);
} else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) &&
!Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) {
// Reset module on inactivity
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
// Clean up virtual keyboard if it exists during timeout
if (graphics::NotificationRenderer::virtualKeyboard) {
LOG_INFO("Cleaning up virtual keyboard due to module timeout");
delete graphics::NotificationRenderer::virtualKeyboard;
graphics::NotificationRenderer::virtualKeyboard = nullptr;
graphics::NotificationRenderer::textInputCallback = nullptr;
graphics::NotificationRenderer::resetBanner();
}
this->notifyObservers(&e);
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) {
if (this->payload == 0) {
// [Exit] button pressed - return to inactive state
LOG_INFO("Processing [Exit] action - returning to inactive state");
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
} else if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
if (this->freetext.length() > 0) {
sendText(this->dest, this->channel, this->freetext.c_str(), true);
this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
} else {
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
}
} else {
if (strcmp(this->messages[this->currentMessageIndex], "[Select Destination]") == 0) {
this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
return INT32_MAX;
}
if ((this->messagesCount > this->currentMessageIndex) && (strlen(this->messages[this->currentMessageIndex]) > 0)) {
if (strcmp(this->messages[this->currentMessageIndex], "~") == 0) {
return INT32_MAX;
} else {
sendText(this->dest, this->channel, this->messages[this->currentMessageIndex], true);
}
this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
} else {
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
}
}
this->currentMessageIndex = -1;
this->freetext = "";
this->cursor = 0;
this->notifyObservers(&e);
return 2000;
}
// Highlight [Select Destination] initially when entering the message list
else if ((this->runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) && (this->currentMessageIndex == -1)) {
int selectDestination = 0;
for (int i = 0; i < this->messagesCount; ++i) {
if (strcmp(this->messages[i], "[Select Destination]") == 0) {
selectDestination = i;
break;
}
}
this->currentMessageIndex = selectDestination;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_UP) {
if (this->messagesCount > 0) {
this->currentMessageIndex = getPrevIndex();
this->freetext = "";
this->cursor = 0;
this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
LOG_DEBUG("MOVE UP (%d):%s", this->currentMessageIndex, this->getCurrentMessage());
}
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_DOWN) {
if (this->messagesCount > 0) {
this->currentMessageIndex = this->getNextIndex();
this->freetext = "";
this->cursor = 0;
this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE;
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) {
// 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) {
case INPUT_BROKER_LEFT:
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor > 0) {
this->cursor--;
}
break;
case INPUT_BROKER_RIGHT:
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor < this->freetext.length()) {
this->cursor++;
}
break;
default:
break;
}
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
switch (this->payload) {
case 0x08: // backspace
if (this->freetext.length() > 0) {
if (this->cursor > 0) {
if (this->cursor == this->freetext.length()) {
this->freetext = this->freetext.substring(0, this->freetext.length() - 1);
} else {
this->freetext = this->freetext.substring(0, this->cursor - 1) +
this->freetext.substring(this->cursor, this->freetext.length());
}
this->cursor--;
}
} else {
}
break;
case INPUT_BROKER_MSG_TAB: // Tab key: handled by input handler
return 0;
case INPUT_BROKER_LEFT:
case INPUT_BROKER_RIGHT:
break;
default:
// Only insert ASCII printable characters (32126)
if (this->payload >= 32 && this->payload <= 126) {
requestFocus();
if (this->cursor == this->freetext.length()) {
this->freetext += (char)this->payload;
} else {
this->freetext = this->freetext.substring(0, this->cursor) + (char)this->payload +
this->freetext.substring(this->cursor);
}
this->cursor++;
// 自动滚动:确保光标在可见区域内
// 假设屏幕宽度约 21 个字符 (128px / 6px)
const int visibleChars = 20;
if (this->cursor > this->textScrollOffset + visibleChars) {
this->textScrollOffset = this->cursor - visibleChars;
}
uint16_t maxChars = meshtastic_Constants_DATA_PAYLOAD_LEN - (moduleConfig.canned_message.send_bell ? 1 : 0);
if (this->freetext.length() > maxChars) {
this->cursor = maxChars;
this->freetext = this->freetext.substring(0, maxChars);
}
}
break;
}
}
this->lastTouchMillis = millis();
this->payload = 0; // Clear payload after processing to prevent re-processing
this->notifyObservers(&e);
return INACTIVATE_AFTER_MS;
}
if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) {
this->lastTouchMillis = millis();
this->notifyObservers(&e);
return INACTIVATE_AFTER_MS;
}
return INT32_MAX;
}
const char *CannedMessageModule::getCurrentMessage()
{
return this->messages[this->currentMessageIndex];
}
const char *CannedMessageModule::getPrevMessage()
{
return this->messages[this->getPrevIndex()];
}
const char *CannedMessageModule::getNextMessage()
{
return this->messages[this->getNextIndex()];
}
const char *CannedMessageModule::getMessageByIndex(int index)
{
return (index >= 0 && index < this->messagesCount) ? this->messages[index] : "";
}
const char *CannedMessageModule::getNodeName(NodeNum node)
{
if (node == NODENUM_BROADCAST)
return "Broadcast";
meshtastic_NodeInfoLite *info = nodeDB->getMeshNode(node);
if (info && info->has_user && strlen(info->user.long_name) > 0) {
return info->user.long_name;
}
static char fallback[12];
snprintf(fallback, sizeof(fallback), "0x%08x", node);
return fallback;
}
bool CannedMessageModule::shouldDraw()
{
return (currentMessageIndex != -1) || (this->runState != CANNED_MESSAGE_RUN_STATE_INACTIVE);
}
// Has the user defined any canned messages?
// Expose publicly whether canned message module is ready for use
bool CannedMessageModule::hasMessages()
{
return (this->messagesCount > 0);
}
int CannedMessageModule::getNextIndex()
{
if (this->currentMessageIndex >= (this->messagesCount - 1)) {
return 0;
} else {
return this->currentMessageIndex + 1;
}
}
int CannedMessageModule::getPrevIndex()
{
if (this->currentMessageIndex <= 0) {
return this->messagesCount - 1;
} else {
return this->currentMessageIndex - 1;
}
}
void CannedMessageModule::showTemporaryMessage(const String &message)
{
temporaryMessage = message;
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen
notifyObservers(&e);
runState = CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION;
// run this loop again in 2 seconds, next iteration will clear the display
setIntervalFromNow(2000);
}
#if defined(USE_VIRTUAL_KEYBOARD)
String CannedMessageModule::keyForCoordinates(uint x, uint y)
{
int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet];
for (int8_t outerIndex = 0; outerIndex < outerSize; outerIndex++) {
int innerSize = *(&this->keyboard[this->charSet][outerIndex] + 1) - this->keyboard[this->charSet][outerIndex];
for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) {
Letter letter = this->keyboard[this->charSet][outerIndex][innerIndex];
if (x > letter.rectX && x < (letter.rectX + letter.rectWidth) && y > letter.rectY &&
y < (letter.rectY + letter.rectHeight)) {
return letter.character;
}
}
}
return "";
}
void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
int outerSize = *(&this->keyboard[this->charSet] + 1) - this->keyboard[this->charSet];
int xOffset = 0;
int yOffset = 56;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->setColor(OLEDDISPLAY_COLOR::WHITE);
// 绘制带滚动的文本输入区域
{
String text = cannedMessageModule->freetext;
unsigned int cursorPos = cannedMessageModule->cursor;
unsigned int scrollOffset = cannedMessageModule->textScrollOffset;
// 计算可见区域宽度(减去滚动条宽度)
int maxWidth = display->getWidth() - 4; // 留出滚动条空间
// 获取显示文本(从 scrollOffset 开始)
String displayText = text.substring(scrollOffset);
// 添加光标
String textWithCursor = displayText.substring(0, cursorPos - scrollOffset) + "_" + displayText.substring(cursorPos - scrollOffset);
// 绘制文本
display->drawStringMaxWidth(0, 0, maxWidth, textWithCursor);
// 如果文本超出可见区域,绘制滚动条
int totalWidth = display->getStringWidth(text);
if (totalWidth > maxWidth) {
// 计算滚动条位置
int scrollbarHeight = 8;
int scrollbarWidth = 3;
int scrollbarX = display->getWidth() - scrollbarWidth - 1;
// 滚动条位置与光标位置关联
float scrollRatio = (float)cursorPos / std::max(1u, (unsigned int)text.length());
int scrollbarY = (display->getHeight() - 20) * scrollRatio;
scrollbarY = std::max(0, std::min(scrollbarY, display->getHeight() - 20 - scrollbarHeight));
// 绘制滚动条
display->setColor(OLEDDISPLAY_COLOR::WHITE);
display->fillRect(scrollbarX, 10, scrollbarWidth, scrollbarHeight);
}
}
display->setFont(FONT_MEDIUM);
int cellHeight = round((display->height() - 64) / outerSize);
int yCorrection = 8;
for (int8_t outerIndex = 0; outerIndex < outerSize; outerIndex++) {
yOffset += outerIndex > 0 ? cellHeight : 0;
int innerSizeBound = *(&this->keyboard[this->charSet][outerIndex] + 1) - this->keyboard[this->charSet][outerIndex];
int innerSize = 0;
for (int8_t innerIndex = 0; innerIndex < innerSizeBound; innerIndex++) {
if (this->keyboard[this->charSet][outerIndex][innerIndex].character != "") {
innerSize++;
}
}
int cellWidth = display->width() / innerSize;
for (int8_t innerIndex = 0; innerIndex < innerSize; innerIndex++) {
xOffset += innerIndex > 0 ? cellWidth : 0;
Letter letter = this->keyboard[this->charSet][outerIndex][innerIndex];
Letter updatedLetter = {letter.character, letter.width, xOffset, yOffset, cellWidth, cellHeight};
#ifdef RAK14014 // Optimize the touch range of the virtual keyboard in the bottom row
if (outerIndex == outerSize - 1) {
updatedLetter.rectHeight = 240 - yOffset;
}
#endif
this->keyboard[this->charSet][outerIndex][innerIndex] = updatedLetter;
float characterOffset = ((cellWidth / 2) - (letter.width / 2));
if (letter.character == "⇧") {
if (this->shift) {
display->fillRect(xOffset, yOffset, cellWidth, cellHeight);
display->setColor(OLEDDISPLAY_COLOR::BLACK);
drawShiftIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2);
display->setColor(OLEDDISPLAY_COLOR::WHITE);
} else {
display->drawRect(xOffset, yOffset, cellWidth, cellHeight);
drawShiftIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2);
}
} else if (letter.character == "⌫") {
if (this->highlight == letter.character[0]) {
display->fillRect(xOffset, yOffset, cellWidth, cellHeight);
display->setColor(OLEDDISPLAY_COLOR::BLACK);
drawBackspaceIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2);
display->setColor(OLEDDISPLAY_COLOR::WHITE);
setIntervalFromNow(0);
} else {
display->drawRect(xOffset, yOffset, cellWidth, cellHeight);
drawBackspaceIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.2);
}
} else if (letter.character == "↵") {
display->drawRect(xOffset, yOffset, cellWidth, cellHeight);
drawEnterIcon(display, xOffset + characterOffset, yOffset + yCorrection + 5, 1.7);
} else {
if (this->highlight == letter.character[0]) {
display->fillRect(xOffset, yOffset, cellWidth, cellHeight);
display->setColor(OLEDDISPLAY_COLOR::BLACK);
display->drawString(xOffset + characterOffset, yOffset + yCorrection,
letter.character == " " ? "space" : letter.character);
display->setColor(OLEDDISPLAY_COLOR::WHITE);
setIntervalFromNow(0);
} else {
display->drawRect(xOffset, yOffset, cellWidth, cellHeight);
display->drawString(xOffset + characterOffset, yOffset + yCorrection,
letter.character == " " ? "space" : letter.character);
}
}
}
xOffset = 0;
}
this->highlight = 0x00;
}
void CannedMessageModule::drawShiftIcon(OLEDDisplay *display, int x, int y, float scale)
{
PointStruct shiftIcon[10] = {{8, 0}, {15, 7}, {15, 8}, {12, 8}, {12, 12}, {4, 12}, {4, 8}, {1, 8}, {1, 7}, {8, 0}};
int size = 10;
for (int i = 0; i < size - 1; i++) {
int x0 = x + (shiftIcon[i].x * scale);
int y0 = y + (shiftIcon[i].y * scale);
int x1 = x + (shiftIcon[i + 1].x * scale);
int y1 = y + (shiftIcon[i + 1].y * scale);
display->drawLine(x0, y0, x1, y1);
}
}
void CannedMessageModule::drawBackspaceIcon(OLEDDisplay *display, int x, int y, float scale)
{
PointStruct backspaceIcon[6] = {{0, 7}, {5, 2}, {15, 2}, {15, 12}, {5, 12}, {0, 7}};
int size = 6;
for (int i = 0; i < size - 1; i++) {
int x0 = x + (backspaceIcon[i].x * scale);
int y0 = y + (backspaceIcon[i].y * scale);
int x1 = x + (backspaceIcon[i + 1].x * scale);
int y1 = y + (backspaceIcon[i + 1].y * scale);
display->drawLine(x0, y0, x1, y1);
}
PointStruct backspaceIconX[4] = {{7, 4}, {13, 10}, {7, 10}, {13, 4}};
size = 4;
for (int i = 0; i < size - 1; i++) {
int x0 = x + (backspaceIconX[i].x * scale);
int y0 = y + (backspaceIconX[i].y * scale);
int x1 = x + (backspaceIconX[i + 1].x * scale);
int y1 = y + (backspaceIconX[i + 1].y * scale);
display->drawLine(x0, y0, x1, y1);
}
}
void CannedMessageModule::drawEnterIcon(OLEDDisplay *display, int x, int y, float scale)
{
PointStruct enterIcon[6] = {{0, 7}, {4, 3}, {4, 11}, {0, 7}, {15, 7}, {15, 0}};
int size = 6;
for (int i = 0; i < size - 1; i++) {
int x0 = x + (enterIcon[i].x * scale);
int y0 = y + (enterIcon[i].y * scale);
int x1 = x + (enterIcon[i + 1].x * scale);
int y1 = y + (enterIcon[i + 1].y * scale);
display->drawLine(x0, y0, x1, y1);
}
}
#endif
// Indicate to screen class that module is handling keyboard input specially (at certain times)
// This prevents the left & right keys being used for nav. between screen frames during text entry.
bool CannedMessageModule::interceptingKeyboardInput()
{
switch (runState) {
case CANNED_MESSAGE_RUN_STATE_DISABLED:
case CANNED_MESSAGE_RUN_STATE_INACTIVE:
return false;
default:
return true;
}
}
// Draw the node/channel selection screen
void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
requestFocus();
display->setColor(WHITE); // Always draw cleanly
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
// === Header ===
int titleY = 2;
String titleText = "Select Destination";
titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]";
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(display->getWidth() / 2, titleY, titleText);
display->setTextAlignment(TEXT_ALIGN_LEFT);
// === List Items ===
int rowYOffset = titleY + (FONT_HEIGHT_SMALL - 4);
int numActiveChannels = this->activeChannelIndices.size();
int totalEntries = numActiveChannels + this->filteredNodes.size();
int columns = 1;
this->visibleRows = (display->getHeight() - (titleY + FONT_HEIGHT_SMALL)) / (FONT_HEIGHT_SMALL - 4);
if (this->visibleRows < 1)
this->visibleRows = 1;
// === Clamp scrolling ===
if (scrollIndex > totalEntries / columns)
scrollIndex = totalEntries / columns;
if (scrollIndex < 0)
scrollIndex = 0;
for (int row = 0; row < visibleRows; row++) {
int itemIndex = scrollIndex + row;
if (itemIndex >= totalEntries)
break;
int xOffset = 0;
int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset;
char entryText[64] = "";
// Draw Channels First
if (itemIndex < numActiveChannels) {
uint8_t channelIndex = this->activeChannelIndices[itemIndex];
snprintf(entryText, sizeof(entryText), "@%s", channels.getName(channelIndex));
}
// Then Draw Nodes
else {
int nodeIndex = itemIndex - numActiveChannels;
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(this->filteredNodes.size())) {
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
if (node) {
if (node->is_favorite) {
#if defined(M5STACK_UNITC6L)
snprintf(entryText, sizeof(entryText), "* %s", node->user.short_name);
} else {
snprintf(entryText, sizeof(entryText), "%s", node->user.short_name);
}
#else
snprintf(entryText, sizeof(entryText), "* %s", node->user.long_name);
} else {
snprintf(entryText, sizeof(entryText), "%s", node->user.long_name);
}
#endif
}
}
}
if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0)
strcpy(entryText, "?");
// === Highlight background (if selected) ===
if (itemIndex == destIndex) {
int scrollPadding = 8; // Reserve space for scrollbar
display->fillRect(0, yOffset + 2, display->getWidth() - scrollPadding, FONT_HEIGHT_SMALL - 5);
display->setColor(BLACK);
}
// === Draw entry text ===
display->drawString(xOffset + 2, yOffset, entryText);
display->setColor(WHITE);
// === Draw key icon (after highlight) ===
if (itemIndex >= numActiveChannels) {
int nodeIndex = itemIndex - numActiveChannels;
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(this->filteredNodes.size())) {
const meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
if (node && hasKeyForNode(node)) {
int iconX = display->getWidth() - key_symbol_width - 15;
int iconY = yOffset + (FONT_HEIGHT_SMALL - key_symbol_height) / 2;
if (itemIndex == destIndex) {
display->setColor(INVERSE);
} else {
display->setColor(WHITE);
}
display->drawXbm(iconX, iconY, key_symbol_width, key_symbol_height, key_symbol);
}
}
}
}
// Scrollbar
if (totalEntries > visibleRows) {
int scrollbarHeight = visibleRows * (FONT_HEIGHT_SMALL - 4);
int totalScrollable = totalEntries;
int scrollTrackX = display->getWidth() - 6;
display->drawRect(scrollTrackX, rowYOffset, 4, scrollbarHeight);
int scrollHeight = (scrollbarHeight * visibleRows) / totalScrollable;
int scrollPos = rowYOffset + (scrollbarHeight * scrollIndex) / totalScrollable;
display->fillRect(scrollTrackX, scrollPos, 4, scrollHeight);
}
}
void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height
const int headerMargin = 2; // Extra pixels below header
const int labelGap = 6;
const int bitmapGapX = 4;
// Find max emote height (assume all same, or precalculated)
int maxEmoteHeight = 0;
for (int i = 0; i < graphics::numEmotes; ++i)
if (graphics::emotes[i].height > maxEmoteHeight)
maxEmoteHeight = graphics::emotes[i].height;
const int rowHeight = maxEmoteHeight + 2;
// Place header at top, then compute start of emote list
int headerY = y;
int listTop = headerY + headerFontHeight + headerMargin;
int _visibleRows = (display->getHeight() - listTop - 2) / rowHeight;
int numEmotes = graphics::numEmotes;
// Clamp highlight index
if (emotePickerIndex < 0)
emotePickerIndex = 0;
if (emotePickerIndex >= numEmotes)
emotePickerIndex = numEmotes - 1;
// Determine which emote is at the top
int topIndex = emotePickerIndex - _visibleRows / 2;
if (topIndex < 0)
topIndex = 0;
if (topIndex > numEmotes - _visibleRows)
topIndex = std::max(0, numEmotes - _visibleRows);
// Draw header/title
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(display->getWidth() / 2, headerY, "Select Emote");
// Draw emote rows
display->setTextAlignment(TEXT_ALIGN_LEFT);
for (int vis = 0; vis < visibleRows; ++vis) {
int emoteIdx = topIndex + vis;
if (emoteIdx >= numEmotes)
break;
const graphics::Emote &emote = graphics::emotes[emoteIdx];
int rowY = listTop + vis * rowHeight;
// Draw highlight box 2px taller than emote (1px margin above and below)
if (emoteIdx == emotePickerIndex) {
display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2);
display->setColor(BLACK);
}
// Emote bitmap (left), 1px margin from highlight bar top
int emoteY = rowY + 1;
display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap);
// Emote label (right of bitmap)
display->setFont(FONT_MEDIUM);
int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2);
display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label);
if (emoteIdx == emotePickerIndex)
display->setColor(WHITE);
}
// Draw scrollbar if needed
if (numEmotes > visibleRows) {
int scrollbarHeight = visibleRows * rowHeight;
int scrollTrackX = display->getWidth() - 6;
display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight);
int scrollBarLen = std::max(6, (scrollbarHeight * visibleRows) / numEmotes);
int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes;
display->fillRect(scrollTrackX, scrollBarPos, 4, scrollBarLen);
}
}
void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
this->displayHeight = display->getHeight(); // Store display height for later use
// === 中文输入模式界面 ===
if (inputMode == InputMode::CHINESE) {
drawChineseInput(display, state, x, y);
return;
}
char buffer[50];
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
// === Draw temporary message if available ===
if (temporaryMessage.length() != 0) {
requestFocus(); // Tell Screen::setFrames to move to our module's frame
LOG_DEBUG("Draw temporary message: %s", temporaryMessage.c_str());
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->setFont(FONT_MEDIUM);
display->drawString(display->getWidth() / 2 + x, 0 + y + 12, temporaryMessage);
return;
}
// === Emote Picker Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) {
drawEmotePickerScreen(display, state, x, y); // <-- Call your emote picker drawer here
return;
}
// === Destination Selection ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
drawDestinationSelectionScreen(display, state, x, y);
return;
}
// === ACK/NACK Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) {
requestFocus();
EINK_ADD_FRAMEFLAG(display, COSMETIC);
display->setTextAlignment(TEXT_ALIGN_CENTER);
#ifdef USE_EINK
display->setFont(FONT_SMALL);
int yOffset = y + 10;
#else
display->setFont(FONT_MEDIUM);
#if defined(M5STACK_UNITC6L)
int yOffset = y;
#else
int yOffset = y + 10;
#endif
#endif
// --- Delivery Status Message ---
if (this->ack) {
if (this->lastSentNode == NODENUM_BROADCAST) {
snprintf(buffer, sizeof(buffer), "Broadcast Sent to\n%s", channels.getName(this->channel));
} else if (this->lastAckHopLimit > this->lastAckHopStart) {
snprintf(buffer, sizeof(buffer), "Delivered (%d hops)\nto %s", this->lastAckHopLimit - this->lastAckHopStart,
getNodeName(this->incoming));
} else {
snprintf(buffer, sizeof(buffer), "Delivered\nto %s", getNodeName(this->incoming));
}
} else {
snprintf(buffer, sizeof(buffer), "Delivery failed\nto %s", getNodeName(this->incoming));
}
// Draw delivery message and compute y-offset after text height
int lineCount = 1;
for (const char *ptr = buffer; *ptr; ptr++) {
if (*ptr == '\n')
lineCount++;
}
display->drawString(display->getWidth() / 2 + x, yOffset, buffer);
#if defined(M5STACK_UNITC6L)
yOffset += lineCount * FONT_HEIGHT_MEDIUM - 5; // only 1 line gap, no extra padding
#else
yOffset += lineCount * FONT_HEIGHT_MEDIUM; // only 1 line gap, no extra padding
#endif
#ifndef USE_EINK
// --- SNR + RSSI Compact Line ---
if (this->ack) {
display->setFont(FONT_SMALL);
#if defined(M5STACK_UNITC6L)
snprintf(buffer, sizeof(buffer), "SNR: %.1f dB \nRSSI: %d", this->lastRxSnr, this->lastRxRssi);
#else
snprintf(buffer, sizeof(buffer), "SNR: %.1f dB RSSI: %d", this->lastRxSnr, this->lastRxRssi);
#endif
display->drawString(display->getWidth() / 2 + x, yOffset, buffer);
}
#endif
return;
}
// === Sending Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) {
EINK_ADD_FRAMEFLAG(display, COSMETIC);
requestFocus();
#ifdef USE_EINK
display->setFont(FONT_SMALL);
#else
display->setFont(FONT_MEDIUM);
#endif
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(display->getWidth() / 2 + x, 0 + y + 12, "Sending...");
return;
}
// === Disabled Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) {
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled.");
return;
}
// === Free Text Input Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
requestFocus();
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
EInkDynamicDisplay *einkDisplay = static_cast<EInkDynamicDisplay *>(display);
einkDisplay->enableUnlimitedFastMode();
#endif
#if defined(USE_VIRTUAL_KEYBOARD)
drawKeyboard(display, state, 0, 0);
#else
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
// --- Draw node/channel header at the top ---
drawHeader(display, x, y, buffer);
// --- Char count right-aligned ---
if (runState != CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
uint16_t charsLeft =
meshtastic_Constants_DATA_PAYLOAD_LEN - this->freetext.length() - (moduleConfig.canned_message.send_bell ? 1 : 0);
snprintf(buffer, sizeof(buffer), "%d left", charsLeft);
display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer);
}
// --- Draw Free Text input with multi-emote support and proper line wrapping ---
display->setColor(WHITE);
{
int inputY = 0 + y + FONT_HEIGHT_SMALL;
// 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
std::vector<std::pair<bool, String>> tokens;
const char *msg = msgWithCursor.c_str();
int msgLen = strlen(msg);
int pos = 0;
while (pos < msgLen) {
const graphics::Emote *foundEmote = nullptr;
int foundLen = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
int labelLen = strlen(label);
if (labelLen == 0)
continue;
if (strncmp(msg + pos, label, labelLen) == 0) {
if (!foundEmote || labelLen > foundLen) {
foundEmote = &graphics::emotes[j];
foundLen = labelLen;
}
}
}
if (foundEmote) {
tokens.emplace_back(true, String(foundEmote->label));
pos += foundLen;
} else {
// Find next emote
int nextEmote = msgLen;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
if (!label || !*label)
continue;
const char *found = strstr(msg + pos, label);
if (found && (found - msg) < nextEmote) {
nextEmote = found - msg;
}
}
int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos);
if (textLen > 0) {
tokens.emplace_back(false, String(msg + pos).substring(0, textLen));
pos += textLen;
} else {
break;
}
}
}
// ===== Advanced word-wrapping (emotes + text, split by word, wrap by char if needed) =====
std::vector<std::vector<std::pair<bool, String>>> lines;
std::vector<std::pair<bool, String>> currentLine;
int lineWidth = 0;
int maxWidth = display->getWidth();
for (auto &token : tokens) {
if (token.first) {
// Emote
int tokenWidth = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
tokenWidth = graphics::emotes[j].width + 2;
break;
}
}
if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
currentLine.push_back(token);
lineWidth += tokenWidth;
} else {
// Text: split by words and wrap inside word if needed
String text = token.second;
pos = 0;
while (pos < static_cast<int>(text.length())) {
// Find next space (or end)
int spacePos = text.indexOf(' ', pos);
int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space
String word = text.substring(pos, endPos);
// Calculate word width: ASCII chars use getStringWidth, CJK use CFONT_W
int wordWidth = 0;
for (int ci = 0; ci < word.length();) {
int utf8len = 1;
uint16_t cp = cfont12_utf8(word.c_str() + ci, &utf8len);
if (cp >= 0x80 && cfont12_find(cp)) {
wordWidth += CFONT_W;
} else {
String oneChar = word.substring(ci, ci + utf8len);
wordWidth += display->getStringWidth(oneChar);
}
ci += utf8len;
}
if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
// If word itself too big, split by character (CJK-aware)
if (wordWidth > maxWidth) {
int charPos = 0;
while (charPos < word.length()) {
int utf8len = 1;
uint16_t cp = cfont12_utf8(word.c_str() + charPos, &utf8len);
int charWidth;
if (cp >= 0x80 && cfont12_find(cp)) {
charWidth = CFONT_W;
} else {
String oneChar = word.substring(charPos, charPos + utf8len);
charWidth = display->getStringWidth(oneChar);
}
if (lineWidth + charWidth > maxWidth && lineWidth > 0) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
currentLine.push_back({false, word.substring(charPos, charPos + utf8len)});
lineWidth += charWidth;
charPos += utf8len;
}
} else {
currentLine.push_back({false, word});
lineWidth += wordWidth;
}
pos = endPos;
}
}
}
if (!currentLine.empty())
lines.push_back(currentLine);
// Draw lines with emotes
int rowHeight = FONT_HEIGHT_SMALL;
int yLine = inputY;
for (auto &line : lines) {
int nextX = x;
for (const auto &token : line) {
if (token.first) {
const graphics::Emote *emote = nullptr;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
emote = &graphics::emotes[j];
break;
}
}
if (emote) {
int emoteYOffset = (rowHeight - emote->height) / 2;
display->drawXbm(nextX, yLine + emoteYOffset, emote->width, emote->height, emote->bitmap);
nextX += emote->width + 2;
}
} else {
// Mixed ASCII + Chinese rendering
const char *str = token.second.c_str();
const char *p = str;
while (*p) {
int utf8len = 1;
uint16_t cp = cfont12_utf8(p, &utf8len);
if (cp >= 0x80 && cfont12_find(cp)) {
// CJK character: draw using Chinese bitmap font
nextX += cfont12_draw(display, nextX, yLine, cp);
} else {
// ASCII / non-CJK: render single char via drawString
String oneChar = String(p).substring(0, utf8len);
display->drawString(nextX, yLine, oneChar);
nextX += display->getStringWidth(oneChar);
}
p += utf8len;
}
}
}
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
return;
}
// === Canned Messages List ===
if (this->messagesCount > 0) {
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
// ====== Precompute per-row heights based on emotes (centered if present) ======
const int baseRowSpacing = FONT_HEIGHT_SMALL - 4;
int topMsg;
std::vector<int> rowHeights;
int _visibleRows;
// Draw header (To: ...)
drawHeader(display, x, y, buffer);
// Shift message list upward by 3 pixels to reduce spacing between header and first message
const int listYOffset = y + FONT_HEIGHT_SMALL - 3;
_visibleRows = (display->getHeight() - listYOffset) / baseRowSpacing;
// Figure out which messages are visible and their needed heights
topMsg = (messagesCount > _visibleRows && currentMessageIndex >= _visibleRows - 1)
? currentMessageIndex - _visibleRows + 2
: 0;
int countRows = std::min(messagesCount, _visibleRows);
// --- Build per-row max height based on all emotes in line ---
for (int i = 0; i < countRows; i++) {
const char *msg = getMessageByIndex(topMsg + i);
int maxEmoteHeight = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
if (!label || !*label)
continue;
const char *search = msg;
while ((search = strstr(search, label))) {
if (graphics::emotes[j].height > maxEmoteHeight)
maxEmoteHeight = graphics::emotes[j].height;
search += strlen(label); // Advance past this emote
}
}
rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2));
}
// --- Draw all message rows with multi-emote support ---
int yCursor = listYOffset;
for (int vis = 0; vis < countRows; vis++) {
int msgIdx = topMsg + vis;
int lineY = yCursor;
const char *msg = getMessageByIndex(msgIdx);
int rowHeight = rowHeights[vis];
bool _highlight = (msgIdx == currentMessageIndex);
// --- Multi-emote tokenization ---
std::vector<std::pair<bool, String>> tokens; // (isEmote, token)
int pos = 0;
int msgLen = strlen(msg);
while (pos < msgLen) {
const graphics::Emote *foundEmote = nullptr;
int foundLen = 0;
// Look for any emote label at this pos (prefer longest match)
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
int labelLen = strlen(label);
if (labelLen == 0)
continue;
if (strncmp(msg + pos, label, labelLen) == 0) {
if (!foundEmote || labelLen > foundLen) {
foundEmote = &graphics::emotes[j];
foundLen = labelLen;
}
}
}
if (foundEmote) {
tokens.emplace_back(true, String(foundEmote->label));
pos += foundLen;
} else {
// Find next emote
int nextEmote = msgLen;
for (int j = 0; j < graphics::numEmotes; j++) {
const char *label = graphics::emotes[j].label;
if (label[0] == 0)
continue;
const char *found = strstr(msg + pos, label);
if (found && (found - msg) < nextEmote) {
nextEmote = found - msg;
}
}
int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos);
if (textLen > 0) {
tokens.emplace_back(false, String(msg + pos).substring(0, textLen));
pos += textLen;
} else {
break;
}
}
}
// --- End multi-emote tokenization ---
// Vertically center based on rowHeight
int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2;
#ifdef USE_EINK
int nextX = x + (_highlight ? 12 : 0);
if (_highlight)
display->drawString(x + 0, lineY + textYOffset, ">");
#else
int scrollPadding = 8;
if (_highlight) {
display->fillRect(x + 0, lineY, display->getWidth() - scrollPadding, rowHeight);
display->setColor(BLACK);
}
int nextX = x + (_highlight ? 2 : 0);
#endif
// Draw all tokens left to right
for (const auto &token : tokens) {
if (token.first) {
// Emote
const graphics::Emote *emote = nullptr;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
emote = &graphics::emotes[j];
break;
}
}
if (emote) {
int emoteYOffset = (rowHeight - emote->height) / 2;
display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap);
nextX += emote->width + 2;
}
} else {
// Text
display->drawString(nextX, lineY + textYOffset, token.second);
nextX += display->getStringWidth(token.second);
}
}
#ifndef USE_EINK
if (_highlight)
display->setColor(WHITE);
#endif
yCursor += rowHeight;
}
// Scrollbar
if (messagesCount > _visibleRows) {
int scrollHeight = display->getHeight() - listYOffset;
int scrollTrackX = display->getWidth() - 6;
display->drawRect(scrollTrackX, listYOffset, 4, scrollHeight);
int barHeight = (scrollHeight * _visibleRows) / messagesCount;
int scrollPos = listYOffset + (scrollHeight * topMsg) / messagesCount;
display->fillRect(scrollTrackX, scrollPos, 4, barHeight);
}
}
}
ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &mp)
{
if (mp.decoded.portnum == meshtastic_PortNum_ROUTING_APP && waitingForAck) {
if (mp.decoded.request_id != 0) {
// Trigger screen refresh for ACK/NACK feedback
UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
requestFocus();
this->runState = CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED;
// Decode the routing response
meshtastic_Routing decoded = meshtastic_Routing_init_default;
pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, meshtastic_Routing_fields, &decoded);
// Track hop metadata
this->lastAckWasRelayed = (mp.hop_limit != mp.hop_start);
this->lastAckHopStart = mp.hop_start;
this->lastAckHopLimit = mp.hop_limit;
// Determine ACK status
bool isAck = (decoded.error_reason == meshtastic_Routing_Error_NONE);
bool isFromDest = (mp.from == this->lastSentNode);
bool wasBroadcast = (this->lastSentNode == NODENUM_BROADCAST);
// Identify the responding node
if (wasBroadcast && mp.from != nodeDB->getNodeNum()) {
this->incoming = mp.from; // Relayed by another node
} else {
this->incoming = this->lastSentNode; // Direct reply
}
// Final ACK confirmation logic
this->ack = isAck && (wasBroadcast || isFromDest);
waitingForAck = false;
this->notifyObservers(&e);
setIntervalFromNow(3000); // Time to show ACK/NACK screen
}
}
return ProcessMessage::CONTINUE;
}
void CannedMessageModule::loadProtoForModule()
{
if (nodeDB->loadProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size,
sizeof(meshtastic_CannedMessageModuleConfig), &meshtastic_CannedMessageModuleConfig_msg,
&cannedMessageModuleConfig) != LoadFileResult::LOAD_SUCCESS) {
installDefaultCannedMessageModuleConfig();
}
}
/**
* @brief Save the module config to file.
*
* @return true On success.
* @return false On error.
*/
bool CannedMessageModule::saveProtoForModule()
{
bool okay = true;
#ifdef FSCom
spiLock->lock();
FSCom.mkdir("/prefs");
spiLock->unlock();
#endif
okay &= nodeDB->saveProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size,
&meshtastic_CannedMessageModuleConfig_msg, &cannedMessageModuleConfig);
return okay;
}
/**
* @brief Fill configuration with default values.
*/
void CannedMessageModule::installDefaultCannedMessageModuleConfig()
{
strncpy(cannedMessageModuleConfig.messages, "Hi|Bye|Yes|No|Ok", sizeof(cannedMessageModuleConfig.messages));
}
/**
* @brief An admin message arrived to AdminModule. We are asked whether we want to handle that.
*
* @param mp The mesh packet arrived.
* @param request The AdminMessage request extracted from the packet.
* @param response The prepared response
* @return AdminMessageHandleResult HANDLED if message was handled
* HANDLED_WITH_RESULT if a result is also prepared.
*/
AdminMessageHandleResult CannedMessageModule::handleAdminMessageForModule(const meshtastic_MeshPacket &mp,
meshtastic_AdminMessage *request,
meshtastic_AdminMessage *response)
{
AdminMessageHandleResult result;
switch (request->which_payload_variant) {
case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag:
LOG_DEBUG("Client getting radio canned messages");
this->handleGetCannedMessageModuleMessages(mp, response);
result = AdminMessageHandleResult::HANDLED_WITH_RESPONSE;
break;
case meshtastic_AdminMessage_set_canned_message_module_messages_tag:
LOG_DEBUG("Client getting radio canned messages");
this->handleSetCannedMessageModuleMessages(request->set_canned_message_module_messages);
result = AdminMessageHandleResult::HANDLED;
break;
default:
result = AdminMessageHandleResult::NOT_HANDLED;
}
return result;
}
void CannedMessageModule::handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req,
meshtastic_AdminMessage *response)
{
LOG_DEBUG("*** handleGetCannedMessageModuleMessages");
if (req.decoded.want_response) {
response->which_payload_variant = meshtastic_AdminMessage_get_canned_message_module_messages_response_tag;
strncpy(response->get_canned_message_module_messages_response, cannedMessageModuleConfig.messages,
sizeof(response->get_canned_message_module_messages_response));
} // Don't send anything if not instructed to. Better than asserting.
}
void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_msg)
{
int changed = 0;
if (*from_msg) {
changed |= strcmp(cannedMessageModuleConfig.messages, from_msg);
strncpy(cannedMessageModuleConfig.messages, from_msg, sizeof(cannedMessageModuleConfig.messages));
LOG_DEBUG("*** from_msg.text:%s", from_msg);
}
if (changed) {
this->saveProtoForModule();
if (splitConfiguredMessages()) {
moduleConfig.canned_message.enabled = true;
}
}
}
String CannedMessageModule::drawWithCursor(String text, int cursor)
{
String result = text.substring(0, cursor) + "_" + text.substring(cursor);
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