diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index fea27661c..13a7e7b17 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -17,9 +17,7 @@ #include "graphics/SharedUIDisplay.h" #include "graphics/draw/MessageRenderer.h" #include "graphics/draw/NotificationRenderer.h" -#include "graphics/emotes.h" #include "graphics/images.h" -#include "input/SerialKeyboard.h" #include "main.h" // for cardkb_found #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "modules/AdminModule.h" @@ -31,9 +29,6 @@ extern MessageStore messageStore; #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 @@ -45,71 +40,6 @@ extern MessageStore messageStore; // Remove Canned message screen if no action is taken for some milliseconds #define INACTIVATE_AFTER_MS 20000 -// Tokenize a message string into emote/text segments -static std::vector> tokenizeMessageWithEmotes(const char *msg) -{ - std::vector> tokens; - 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; - } - } - } - return tokens; -} - -// Render a single emote token centered vertically on a row -static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label) -{ - const graphics::Emote *emote = nullptr; - for (int j = 0; j < graphics::numEmotes; j++) { - if (label == graphics::emotes[j].label) { - emote = &graphics::emotes[j]; - break; - } - } - if (emote) { - int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote - display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap); - nextX += emote->width + 2; // spacing between tokens - } -} - namespace graphics { extern int bannerSignalBars; @@ -118,9 +48,9 @@ extern ScanI2C::DeviceAddress cardkb_found; 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; +NodeNum lastDest = NODENUM_BROADCAST; +uint8_t lastChannel = 0; +bool lastDestSet = false; meshtastic_CannedMessageModuleConfig cannedMessageModuleConfig; @@ -203,7 +133,6 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t LOG_DEBUG("[CannedMessage] LaunchFreetextWithDestination dest=0x%08x ch=%d", dest, channel); } -static bool returnToCannedList = false; bool hasKeyForNode(const meshtastic_NodeInfoLite *node) { return node && node->has_user && node->user.public_key.size > 0; @@ -281,92 +210,6 @@ void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, } } -void CannedMessageModule::resetSearch() -{ - 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)}); - } - } - } - - meshtastic_MeshPacket *p = allocDataPacket(); - p->pki_encrypted = true; - p->channel = 0; - - // Populate active channels - std::vector 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); - } - } - - 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 { @@ -430,16 +273,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) LaunchWithDestination(NODENUM_BROADCAST); return 1; } - // Printable char (ASCII) opens free text compose - if (event->kbchar >= 32 && event->kbchar <= 126) { - runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; - requestFocus(); - UIFrameEvent e; - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - notifyObservers(&e); - // Immediately process the input in the new state (freetext) - return handleFreeTextInput(event); - } + if (tryStartFreeTextFromInactive(event)) + return 1; return 0; break; @@ -498,622 +333,6 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event) 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(activeChannelIndices.size())) { - dest = NODENUM_BROADCAST; - channel = activeChannelIndices[destIndex]; - lastDest = dest; - lastChannel = channel; - lastDestSet = true; - } else { - int nodeIndex = destIndex - static_cast(activeChannelIndices.size()); - if (nodeIndex >= 0 && nodeIndex < static_cast(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) -{ - // 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: #%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 - const int savedIndex = currentMessageIndex; - graphics::menuHandler::showConfirmationBanner("Send message?", [this, savedIndex]() { - this->currentMessageIndex = savedIndex; - this->payload = this->runState; - this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; - this->setIntervalFromNow(0); - }); -#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 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) { - // When multiple predictions exist, SELECT accepts the currently highlighted - // suggestion first. Press SELECT again to send. - if (this->cursor == this->freetext.length() && this->freeTextPredictionCount > 0 && - this->acceptFreeTextPrediction(true)) { - this->payload = 0; - this->lastTouchMillis = millis(); - requestFocus(); - runOnce(); - return true; - } - - 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(); - requestFocus(); - runOnce(); - return true; - } - - // Move cursor left - if (event->inputEvent == INPUT_BROKER_LEFT) { - payload = INPUT_BROKER_LEFT; - lastTouchMillis = millis(); - requestFocus(); - runOnce(); - return true; - } - // Move cursor right - if (event->inputEvent == INPUT_BROKER_RIGHT) { - payload = INPUT_BROKER_RIGHT; - lastTouchMillis = millis(); - requestFocus(); - runOnce(); - 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)) { - 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 - } - - // Printable ASCII (add char to draft) - if (event->kbchar >= 32 && event->kbchar <= 126) { - payload = event->kbchar; - lastTouchMillis = millis(); - runOnce(); - 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; -} - -void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies) -{ - lastDest = dest; - lastChannel = channel; - lastDestSet = true; - - meshtastic_MeshPacket *p = allocDataPacket(); - p->to = dest; - p->channel = channel; - p->want_ack = true; - p->decoded.dest = dest; // Mirror picker: NODENUM_BROADCAST or node->num - - this->lastSentNode = dest; - this->incoming = dest; - - // Manually find the node by number to check PKI capability - meshtastic_NodeInfoLite *node = nullptr; - size_t numMeshNodes = nodeDB->getNumMeshNodes(); - for (size_t i = 0; i < numMeshNodes; ++i) { - meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); - if (n && n->num == dest) { - node = n; - break; - } - } - - NodeNum myNodeNum = nodeDB->getNodeNum(); - if (node && node->num != myNodeNum && node->has_user && node->user.public_key.size == 32) { - p->pki_encrypted = true; - p->channel = 0; // force PKI - } - - // Track this packet’s request ID for matching ACKs - this->lastRequestId = p->id; - - // Copy payload - p->decoded.payload.size = strlen(message); - memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); - - if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { - p->decoded.payload.bytes[p->decoded.payload.size++] = 7; - p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; - } - - this->waitingForAck = true; - - // Send to mesh (PKI-encrypted if conditions above matched) - service->sendToMesh(p, RX_SRC_LOCAL, true); - - // Show banner immediately - if (screen) { - graphics::BannerOverlayOptions opts; - opts.message = "Sending..."; - opts.durationMs = 2000; - screen->showOverlayBanner(opts); - } - - // Save outgoing message - StoredMessage sm; - - // Always use our local time, consistent with other paths - uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice, true); - sm.timestamp = (nowSecs > 0) ? nowSecs : millis() / 1000; - sm.isBootRelative = (nowSecs == 0); - - sm.sender = nodeDB->getNodeNum(); // us - sm.channelIndex = channel; - size_t len = strnlen(message, MAX_MESSAGE_SIZE - 1); - sm.textOffset = MessageStore::storeText(message, len); - sm.textLength = len; - - // Classify broadcast vs DM - if (dest == NODENUM_BROADCAST) { - sm.dest = NODENUM_BROADCAST; - sm.type = MessageType::BROADCAST; - } else { - sm.dest = dest; - sm.type = MessageType::DM_TO_US; - // Only add as favorite if our role is NOT CLIENT_BASE - if (config.device.role != 12) { - LOG_INFO("Proactively adding %x as favorite node", dest); - nodeDB->set_favorite(true, dest); - } else { - LOG_DEBUG("Not favoriting node %x as we are CLIENT_BASE role", dest); - } - } - sm.ackStatus = AckStatus::NONE; - - messageStore.addLiveMessage(std::move(sm)); - - // Auto-switch thread view on outgoing message - if (sm.type == MessageType::BROADCAST) { - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, sm.channelIndex); - } else { - graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, sm.dest); - } - - playComboTune(); - - this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; - this->payload = wantReplies ? 1 : 0; - requestFocus(); - - // Tell Screen to switch to TextMessage frame via UIFrameEvent - UIFrameEvent e; - e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE; - notifyObservers(&e); -} - int32_t CannedMessageModule::runOnce() { if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && needsUpdate) { @@ -1299,82 +518,8 @@ int32_t CannedMessageModule::runOnce() this->cursor = 0; this->runState = CANNED_MESSAGE_RUN_STATE_ACTIVE; } - } else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { - switch (this->payload) { - case INPUT_BROKER_LEFT: - if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { - if (this->cursor == this->freetext.length() && this->freeTextPredictionCount > 1) { - this->cycleFreeTextPrediction(-1); - } else if (this->cursor > 0) { - this->cursor--; - } - } - break; - case INPUT_BROKER_RIGHT: - if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { - if (this->cursor < this->freetext.length()) { - this->cursor++; - } else if (this->freeTextPredictionCount > 1) { - this->cycleFreeTextPrediction(1); - } else if (this->freeTextPredictionCount > 0) { - this->acceptFreeTextPrediction(true); - } else { - // Keep right-at-end as navigation only. - } - } - break; - default: - break; - } - if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - switch (this->payload) { - case 0x08: // backspace - this->freeTextPredictionSuppressed = false; - 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 (32–126) - if (this->payload >= 32 && this->payload <= 126) { - this->freeTextPredictionSuppressed = false; - requestFocus(); - if (this->cursor == this->freetext.length()) { - this->freetext += static_cast(this->payload); - this->cursor++; - } else { - this->freetext = this->freetext.substring(0, this->cursor) + static_cast(this->payload) + - this->freetext.substring(this->cursor); - this->cursor++; - } - const uint16_t maxChars = 200 - (moduleConfig.canned_message.send_bell ? 1 : 0); - if (this->freetext.length() > maxChars) { - this->cursor = maxChars; - this->freetext = this->freetext.substring(0, maxChars); - } - } - break; - } - this->updateFreeTextPrediction(); - } - this->lastTouchMillis = millis(); - this->notifyObservers(&e); - return INACTIVATE_AFTER_MS; + } else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + return runFreeTextState(e); } if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) { @@ -1664,237 +809,23 @@ bool CannedMessageModule::interceptingKeyboardInput() } // 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(this->filteredNodes.size())) { - meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; - if (node && node->user.long_name) { - strncpy(entryText, node->user.long_name, sizeof(entryText) - 1); - entryText[sizeof(entryText) - 1] = '\0'; - } - int availWidth = display->getWidth() - - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - - ((node && node->is_favorite) ? 10 : 0); - if (availWidth < 0) - availWidth = 0; - - size_t origLen = strlen(entryText); - while (entryText[0] && display->getStringWidth(entryText) > availWidth) { - entryText[strlen(entryText) - 1] = '\0'; - } - if (strlen(entryText) < origLen) { - strcat(entryText, "..."); - } - - // Prepend "* " if this is a favorite - if (node && node->is_favorite) { - size_t len = strlen(entryText); - if (len + 2 < sizeof(entryText)) { - memmove(entryText + 2, entryText, len + 1); - entryText[0] = '*'; - entryText[1] = ' '; - } - } - if (node) { - if (display->getWidth() <= 64) { - snprintf(entryText, sizeof(entryText), "%s", node->user.short_name); - } - } - } - } - - 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(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; - - // keep member variable in sync - this->visibleRows = _visibleRows; - - // 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 + this->displayHeight = display->getHeight(); // Store display height for later use. char buffer[50]; display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); - // Never draw if state is outside our UI modes + // Never draw if state is outside our UI modes. if (!(runState == CANNED_MESSAGE_RUN_STATE_ACTIVE || runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || - runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION || runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER)) { - return; // bail if not in a UI state that should render + runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION || runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER || + runState == CANNED_MESSAGE_RUN_STATE_DISABLED)) { + return; } // Emote Picker Screen if (this->runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) { - drawEmotePickerScreen(display, state, x, y); // <-- Call your emote picker drawer here + drawEmotePickerScreen(display, state, x, y); return; } @@ -1906,529 +837,20 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // 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(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); - } - -#if INPUTBROKER_SERIAL_TYPE == 1 - // Chatter Modifier key mode label (right side) - { - uint8_t mode = globalSerialKeyboard ? globalSerialKeyboard->getShift() : 0; - const char *label = (mode == 0) ? "a" : (mode == 1) ? "A" : "#"; - - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const int16_t th = FONT_HEIGHT_SMALL; - const int16_t tw = display->getStringWidth(label); - const int16_t padX = 3; - const int16_t padY = 2; - const int16_t r = 3; - - const int16_t bw = tw + padX * 2; - const int16_t bh = th + padY * 2; - - const int16_t bx = x + display->getWidth() - bw - 2; - const int16_t by = y + display->getHeight() - bh - 2; - - display->setColor(WHITE); - display->fillRect(bx + r, by, bw - r * 2, bh); - display->fillRect(bx, by + r, r, bh - r * 2); - display->fillRect(bx + bw - r, by + r, r, bh - r * 2); - display->fillCircle(bx + r, by + r, r); - display->fillCircle(bx + bw - r - 1, by + r, r); - display->fillCircle(bx + r, by + bh - r - 1, r); - display->fillCircle(bx + bw - r - 1, by + bh - r - 1, r); - - display->setColor(BLACK); - display->drawString(bx + padX, by + padY, label); - } - - // LEFT-SIDE DESTINATION-HINT BOX (“Dest: Shift + ◄”) - { - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const char *label = "Dest: Shift + "; - int16_t labelW = display->getStringWidth(label); - - // triangle size visually matches glyph height, not full line height - const int triH = FONT_HEIGHT_SMALL - 3; - const int triW = triH * 0.7; - - const int16_t padX = 3; - const int16_t padY = 2; - const int16_t r = 3; - - const int16_t bw = labelW + triW + padX * 2 + 2; - const int16_t bh = FONT_HEIGHT_SMALL + padY * 2; - - const int16_t bx = x + 2; - const int16_t by = y + display->getHeight() - bh - 2; - - // Rounded white box - display->setColor(WHITE); - display->fillRect(bx + r, by, bw - (r * 2), bh); - display->fillRect(bx, by + r, r, bh - (r * 2)); - display->fillRect(bx + bw - r, by + r, r, bh - (r * 2)); - display->fillCircle(bx + r, by + r, r); - display->fillCircle(bx + bw - r - 1, by + r, r); - display->fillCircle(bx + r, by + bh - r - 1, r); - display->fillCircle(bx + bw - r - 1, by + bh - r - 1, r); - - // Draw text - display->setColor(BLACK); - display->drawString(bx + padX, by + padY, label); - - // Perfectly center triangle on text baseline - int16_t tx = bx + padX + labelW; - int16_t ty = by + padY + (FONT_HEIGHT_SMALL / 2) - (triH / 2) - 1; // -1 for optical centering - - // ◄ Left-pointing triangle - display->fillTriangle(tx + triW, ty, // top-right - tx, ty + triH / 2, // left center - tx + triW, ty + triH // bottom-right - ); - } -#endif - // Draw Free Text input with multi-emote support and proper line wrapping - display->setColor(WHITE); - { - const int inputTopOffset = -3; - int inputY = y + FONT_HEIGHT_SMALL + inputTopOffset; - String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); - - // Tokenize input into (isEmote, token) pairs - const char *msg = msgWithCursor.c_str(); - std::vector> tokens = tokenizeMessageWithEmotes(msg); - - // Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed) - std::vector>> lines; - std::vector> 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; - int pos = 0; - while (pos < static_cast(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); - int wordWidth = display->getStringWidth(word); - - if (lineWidth + wordWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - // If word itself too big, split by character - if (wordWidth > maxWidth) { - uint16_t charPos = 0; - while (charPos < word.length()) { - String oneChar = word.substring(charPos, charPos + 1); - int charWidth = display->getStringWidth(oneChar); - if (lineWidth + charWidth > maxWidth && lineWidth > 0) { - lines.push_back(currentLine); - currentLine.clear(); - lineWidth = 0; - } - currentLine.push_back({false, oneChar}); - lineWidth += charWidth; - charPos++; - } - } else { - currentLine.push_back({false, word}); - lineWidth += wordWidth; - } - pos = endPos; - } - } - } - if (!currentLine.empty()) - lines.push_back(currentLine); - - const int rowHeight = std::max(8, FONT_HEIGHT_SMALL - 3); - const int viewportTop = inputY; - const int viewportBottom = y + display->getHeight(); - const int viewportHeight = std::max(1, viewportBottom - viewportTop); - const int viewportRows = std::max(1, viewportHeight / rowHeight); - - int cursorRow = static_cast(lines.size()) - 1; - if (cursorRow < 0) { - cursorRow = 0; - } - if (this->cursor < this->freetext.length()) { - for (int lineIdx = 0; lineIdx < static_cast(lines.size()); ++lineIdx) { - bool hasCursorMarker = false; - for (const auto &token : lines[lineIdx]) { - if (!token.first && token.second.indexOf('_') >= 0) { - hasCursorMarker = true; - break; - } - } - if (hasCursorMarker) { - cursorRow = lineIdx; - break; - } - } - } - - String predictionPrefix = this->getFreeTextPrefix(); - const bool showPredictionRow = - (this->cursor == this->freetext.length() && this->freeTextPredictionCount > 1 && predictionPrefix.length() >= 2); - const int totalRows = static_cast(lines.size()) + (showPredictionRow ? 1 : 0); - int scrollRows = std::max(0, totalRows - viewportRows); - int targetRow = cursorRow + (showPredictionRow ? 1 : 0); - if (targetRow < scrollRows) { - scrollRows = targetRow; - } - if (targetRow >= (scrollRows + viewportRows)) { - scrollRows = targetRow - viewportRows + 1; - } - if (scrollRows < 0) { - scrollRows = 0; - } - - // Draw wrapped text rows with vertical scrolling. - for (int rowIdx = 0; rowIdx < static_cast(lines.size()); ++rowIdx) { - int yLine = viewportTop + ((rowIdx - scrollRows) * rowHeight); - if (yLine < viewportTop || (yLine + rowHeight) > (viewportBottom + rowHeight)) { - continue; - } - - int nextX = x; - for (const auto &token : lines[rowIdx]) { - if (token.first) { - renderEmote(display, nextX, yLine, rowHeight, token.second); - } else { - display->drawString(nextX, yLine, token.second); - nextX += display->getStringWidth(token.second); - } - } - } - - if (showPredictionRow) { - this->drawFreeTextPredictionRow(display, x, viewportTop, viewportBottom, rowHeight, - static_cast(lines.size()), scrollRows, predictionPrefix); - } - } -#endif + drawFreeTextScreen(display, state, x, y, buffer); 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 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> tokens = tokenizeMessageWithEmotes(msg); - - // 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 rendering centralized in helper - renderEmote(display, nextX, lineY, rowHeight, token.second); - } 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); - } - } -} - -// Return SNR limit based on modem preset -static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset) -{ - switch (preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - return -6.0f; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - return -5.5f; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - return -4.5f; - default: - return -6.0f; - } -} - -// Return Good/Fair/Bad label and set 1–5 bars based on SNR and RSSI -static const char *getSignalGrade(float snr, int32_t rssi, float snrLimit, int &bars) -{ - // 5-bar logic: strength inside Good/Fair/Bad category - if (snr > snrLimit && rssi > -10) { - bars = 5; // very strong good - return "Good"; - } else if (snr > snrLimit && rssi > -20) { - bars = 4; // normal good - return "Good"; - } else if (snr > 0 && rssi > -50) { - bars = 3; // weaker good (on edge of fair) - return "Good"; - } else if (snr > -10 && rssi > -100) { - bars = 2; // fair - return "Fair"; - } else { - bars = 1; // bad - return "Bad"; - } -} - -ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &mp) -{ - // Only process routing ACK/NACK packets that are responses to our own outbound - if (mp.decoded.portnum == meshtastic_PortNum_ROUTING_APP && waitingForAck && mp.to == nodeDB->getNodeNum() && - mp.decoded.request_id == this->lastRequestId) // only ACKs for our last sent packet - { - if (mp.decoded.request_id != 0) { - // 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); - - // Determine ACK/NACK 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/NACK logic - if (wasBroadcast) { - // Any ACK counts for broadcast - this->ack = isAck; - waitingForAck = false; - } else if (isFromDest) { - // Only ACK from destination counts as final - this->ack = isAck; - waitingForAck = false; - } else if (isAck) { - // Relay ACK → mark as RELAYED, still no final ACK - this->ack = false; - waitingForAck = false; - } else { - // Explicit failure - this->ack = false; - waitingForAck = false; - } - - // Update last sent StoredMessage with ACK/NACK/RELAYED result - if (!messageStore.getMessages().empty()) { - StoredMessage &last = const_cast(messageStore.getMessages().back()); - if (last.sender == nodeDB->getNodeNum()) { // only update our own messages - if (wasBroadcast && isAck) { - last.ackStatus = AckStatus::ACKED; - } else if (isFromDest && isAck) { - last.ackStatus = AckStatus::ACKED; - } else if (!isFromDest && isAck) { - last.ackStatus = AckStatus::RELAYED; - } else { - last.ackStatus = AckStatus::NACKED; - } - } - } - - // Capture radio metrics - this->lastRxRssi = mp.rx_rssi; - this->lastRxSnr = mp.rx_snr; - - // Show overlay banner - if (screen) { - auto *display = screen->getDisplayDevice(); - graphics::BannerOverlayOptions opts; - static char buf[128]; - - const char *channelName = channels.getName(this->channel); - const char *src = getNodeName(this->incoming); - char nodeName[48]; - strncpy(nodeName, src, sizeof(nodeName) - 1); - nodeName[sizeof(nodeName) - 1] = '\0'; - - int availWidth = - display->getWidth() - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 60 : 30); - if (availWidth < 0) - availWidth = 0; - - size_t origLen = strlen(nodeName); - while (nodeName[0] && display->getStringWidth(nodeName) > availWidth) { - nodeName[strlen(nodeName) - 1] = '\0'; - } - if (strlen(nodeName) < origLen) { - strcat(nodeName, "..."); - } - - // Calculate signal quality and bars based on preset, SNR, and RSSI - float snrLimit = getSnrLimit(config.lora.modem_preset); - int bars = 0; - const char *qualityLabel = getSignalGrade(this->lastRxSnr, this->lastRxRssi, snrLimit, bars); - - if (this->ack) { - if (this->lastSentNode == NODENUM_BROADCAST) { - snprintf(buf, sizeof(buf), "Message sent to\n#%s\n\nSignal: %s", - (channelName && channelName[0]) ? channelName : "unknown", qualityLabel); - } else { - snprintf(buf, sizeof(buf), "DM sent to\n@%s\n\nSignal: %s", - (nodeName && nodeName[0]) ? nodeName : "unknown", qualityLabel); - } - } else if (isAck && !isFromDest) { - // Relay ACK banner - snprintf(buf, sizeof(buf), "DM Relayed\n(Status Unknown)\n%s\n\nSignal: %s", - (nodeName && nodeName[0]) ? nodeName : "unknown", qualityLabel); - } else { - if (this->lastSentNode == NODENUM_BROADCAST) { - snprintf(buf, sizeof(buf), "Message failed to\n#%s", - (channelName && channelName[0]) ? channelName : "unknown"); - } else { - snprintf(buf, sizeof(buf), "DM failed to\n@%s", (nodeName && nodeName[0]) ? nodeName : "unknown"); - } - } - - opts.message = buf; - opts.durationMs = 3000; - graphics::bannerSignalBars = bars; // tell banner renderer how many bars to draw - screen->showOverlayBanner(opts); // this triggers drawNotificationBox() - } - } - } - - return ProcessMessage::CONTINUE; + drawCannedMessageListScreen(display, state, x, y, buffer); } +// Load canned-message module protobuf config, or install defaults on first boot. void CannedMessageModule::loadProtoForModule() { if (nodeDB->loadProto(cannedMessagesConfigFile, meshtastic_CannedMessageModuleConfig_size, diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index 6c6466dac..6d1658b4f 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -148,12 +148,12 @@ class CannedMessageModule : public SinglePortModule, public Observable +#include + +extern MessageStore messageStore; +extern bool osk_found; +extern NodeNum lastDest; +extern uint8_t lastChannel; +extern bool lastDestSet; +namespace graphics +{ +extern int bannerSignalBars; +} + +// Tracks whether destination-picker cancel/select should return to canned list vs freetext. +static bool returnToCannedList = false; + +// Reset destination search state and keep the previous selection roughly centered. +void CannedMessageModule::resetSearch() +{ + 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(); +} + +// Rebuild searchable destination entries (channels + eligible nodes). +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)}); + } + } + } + + meshtastic_MeshPacket *p = allocDataPacket(); + p->pki_encrypted = true; + p->channel = 0; + + // Populate active channels + std::vector 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); + } + } + + 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(); + } +} + +// Handle destination picker input: type-to-filter, navigation, select, and cancel. +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(activeChannelIndices.size())) { + dest = NODENUM_BROADCAST; + channel = activeChannelIndices[destIndex]; + lastDest = dest; + lastChannel = channel; + lastDestSet = true; + } else { + int nodeIndex = destIndex - static_cast(activeChannelIndices.size()); + if (nodeIndex >= 0 && nodeIndex < static_cast(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; +} + +// Handle canned-message list input including destination/freetext/exit actions. +bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect) +{ + // 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: #%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 + const int savedIndex = currentMessageIndex; + graphics::menuHandler::showConfirmationBanner("Send message?", [this, savedIndex]() { + this->currentMessageIndex = savedIndex; + this->payload = this->runState; + this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; + this->setIntervalFromNow(0); + }); +#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; +} + +// Build and send a text packet, persist it locally, and switch to text-message view. +void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const char *message, bool wantReplies) +{ + lastDest = dest; + lastChannel = channel; + lastDestSet = true; + + meshtastic_MeshPacket *p = allocDataPacket(); + p->to = dest; + p->channel = channel; + p->want_ack = true; + p->decoded.dest = dest; // Mirror picker: NODENUM_BROADCAST or node->num + + this->lastSentNode = dest; + this->incoming = dest; + + // Manually find the node by number to check PKI capability + meshtastic_NodeInfoLite *node = nullptr; + size_t numMeshNodes = nodeDB->getNumMeshNodes(); + for (size_t i = 0; i < numMeshNodes; ++i) { + meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); + if (n && n->num == dest) { + node = n; + break; + } + } + + NodeNum myNodeNum = nodeDB->getNodeNum(); + if (node && node->num != myNodeNum && node->has_user && node->user.public_key.size == 32) { + p->pki_encrypted = true; + p->channel = 0; // force PKI + } + + // Track this packet’s request ID for matching ACKs + this->lastRequestId = p->id; + + // Copy payload + p->decoded.payload.size = strlen(message); + memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size); + + if (moduleConfig.canned_message.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) { + p->decoded.payload.bytes[p->decoded.payload.size++] = 7; + p->decoded.payload.bytes[p->decoded.payload.size] = '\0'; + } + + this->waitingForAck = true; + + // Send to mesh (PKI-encrypted if conditions above matched) + service->sendToMesh(p, RX_SRC_LOCAL, true); + + // Show banner immediately + if (screen) { + graphics::BannerOverlayOptions opts; + opts.message = "Sending..."; + opts.durationMs = 2000; + screen->showOverlayBanner(opts); + } + + // Save outgoing message + StoredMessage sm; + + // Always use our local time, consistent with other paths + uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice, true); + sm.timestamp = (nowSecs > 0) ? nowSecs : millis() / 1000; + sm.isBootRelative = (nowSecs == 0); + + sm.sender = nodeDB->getNodeNum(); // us + sm.channelIndex = channel; + size_t len = strnlen(message, MAX_MESSAGE_SIZE - 1); + sm.textOffset = MessageStore::storeText(message, len); + sm.textLength = len; + + // Classify broadcast vs DM + if (dest == NODENUM_BROADCAST) { + sm.dest = NODENUM_BROADCAST; + sm.type = MessageType::BROADCAST; + } else { + sm.dest = dest; + sm.type = MessageType::DM_TO_US; + // Only add as favorite if our role is NOT CLIENT_BASE + if (config.device.role != 12) { + LOG_INFO("Proactively adding %x as favorite node", dest); + nodeDB->set_favorite(true, dest); + } else { + LOG_DEBUG("Not favoriting node %x as we are CLIENT_BASE role", dest); + } + } + sm.ackStatus = AckStatus::NONE; + + messageStore.addLiveMessage(std::move(sm)); + + // Auto-switch thread view on outgoing message + if (sm.type == MessageType::BROADCAST) { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::CHANNEL, sm.channelIndex); + } else { + graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::DIRECT, -1, sm.dest); + } + + playComboTune(); + + this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; + this->payload = wantReplies ? 1 : 0; + requestFocus(); + + // Tell Screen to switch to TextMessage frame via UIFrameEvent + UIFrameEvent e; + e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE; + notifyObservers(&e); +} + +// Draw the destination picker list with highlight, truncation, and scrollbar. +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(this->filteredNodes.size())) { + meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; + if (node && node->user.long_name) { + strncpy(entryText, node->user.long_name, sizeof(entryText) - 1); + entryText[sizeof(entryText) - 1] = '\0'; + } + int availWidth = display->getWidth() - + ((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) - + ((node && node->is_favorite) ? 10 : 0); + if (availWidth < 0) + availWidth = 0; + + size_t origLen = strlen(entryText); + while (entryText[0] && display->getStringWidth(entryText) > availWidth) { + entryText[strlen(entryText) - 1] = '\0'; + } + if (strlen(entryText) < origLen) { + strcat(entryText, "..."); + } + + // Prepend "* " if this is a favorite + if (node && node->is_favorite) { + size_t len = strlen(entryText); + if (len + 2 < sizeof(entryText)) { + memmove(entryText + 2, entryText, len + 1); + entryText[0] = '*'; + entryText[1] = ' '; + } + } + if (node) { + if (display->getWidth() <= 64) { + snprintf(entryText, sizeof(entryText), "%s", node->user.short_name); + } + } + } + } + + 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(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); + } +} + +// Draw the canned messages list with selection highlight and emote-aware row layout. +void CannedMessageModule::drawCannedMessageListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, + char *buffer) +{ + (void)state; + if (this->messagesCount <= 0) { + return; + } + + 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 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> tokens = freeTextModule::tokenizeMessageWithEmotes(msg); + + // 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 rendering centralized in helper. + freeTextModule::renderEmote(display, nextX, lineY, rowHeight, token.second); + } 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); + } +} + +// Map modem preset to a rough SNR quality threshold used for banner grading. +static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + switch (preset) { + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + return -6.0f; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + return -5.5f; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + return -4.5f; + default: + return -6.0f; + } +} + +// Convert RSSI/SNR into user-facing quality label and 1-5 signal bars. +static const char *getSignalGrade(float snr, int32_t rssi, float snrLimit, int &bars) +{ + // 5-bar logic: strength inside Good/Fair/Bad category + if (snr > snrLimit && rssi > -10) { + bars = 5; // very strong good + return "Good"; + } else if (snr > snrLimit && rssi > -20) { + bars = 4; // normal good + return "Good"; + } else if (snr > 0 && rssi > -50) { + bars = 3; // weaker good (on edge of fair) + return "Good"; + } else if (snr > -10 && rssi > -100) { + bars = 2; // fair + return "Fair"; + } else { + bars = 1; // bad + return "Bad"; + } +} + +// Process routing ACK/NACK for the most recent outbound message and show status banner. +ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + // Only process routing ACK/NACK packets that are responses to our own outbound + if (mp.decoded.portnum == meshtastic_PortNum_ROUTING_APP && waitingForAck && mp.to == nodeDB->getNodeNum() && + mp.decoded.request_id == this->lastRequestId) // only ACKs for our last sent packet + { + if (mp.decoded.request_id != 0) { + // 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); + + // Determine ACK/NACK 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/NACK logic + if (wasBroadcast) { + // Any ACK counts for broadcast + this->ack = isAck; + waitingForAck = false; + } else if (isFromDest) { + // Only ACK from destination counts as final + this->ack = isAck; + waitingForAck = false; + } else if (isAck) { + // Relay ACK → mark as RELAYED, still no final ACK + this->ack = false; + waitingForAck = false; + } else { + // Explicit failure + this->ack = false; + waitingForAck = false; + } + + // Update last sent StoredMessage with ACK/NACK/RELAYED result + if (!messageStore.getMessages().empty()) { + StoredMessage &last = const_cast(messageStore.getMessages().back()); + if (last.sender == nodeDB->getNodeNum()) { // only update our own messages + if (wasBroadcast && isAck) { + last.ackStatus = AckStatus::ACKED; + } else if (isFromDest && isAck) { + last.ackStatus = AckStatus::ACKED; + } else if (!isFromDest && isAck) { + last.ackStatus = AckStatus::RELAYED; + } else { + last.ackStatus = AckStatus::NACKED; + } + } + } + + // Capture radio metrics + this->lastRxRssi = mp.rx_rssi; + this->lastRxSnr = mp.rx_snr; + + // Show overlay banner + if (screen) { + auto *display = screen->getDisplayDevice(); + graphics::BannerOverlayOptions opts; + static char buf[128]; + + const char *channelName = channels.getName(this->channel); + const char *src = getNodeName(this->incoming); + char nodeName[48]; + strncpy(nodeName, src, sizeof(nodeName) - 1); + nodeName[sizeof(nodeName) - 1] = '\0'; + + int availWidth = + display->getWidth() - ((graphics::currentResolution == graphics::ScreenResolution::High) ? 60 : 30); + if (availWidth < 0) + availWidth = 0; + + size_t origLen = strlen(nodeName); + while (nodeName[0] && display->getStringWidth(nodeName) > availWidth) { + nodeName[strlen(nodeName) - 1] = '\0'; + } + if (strlen(nodeName) < origLen) { + strcat(nodeName, "..."); + } + + // Calculate signal quality and bars based on preset, SNR, and RSSI + float snrLimit = getSnrLimit(config.lora.modem_preset); + int bars = 0; + const char *qualityLabel = getSignalGrade(this->lastRxSnr, this->lastRxRssi, snrLimit, bars); + + if (this->ack) { + if (this->lastSentNode == NODENUM_BROADCAST) { + snprintf(buf, sizeof(buf), "Message sent to\n#%s\n\nSignal: %s", + (channelName && channelName[0]) ? channelName : "unknown", qualityLabel); + } else { + snprintf(buf, sizeof(buf), "DM sent to\n@%s\n\nSignal: %s", + (nodeName && nodeName[0]) ? nodeName : "unknown", qualityLabel); + } + } else if (isAck && !isFromDest) { + // Relay ACK banner + snprintf(buf, sizeof(buf), "DM Relayed\n(Status Unknown)\n%s\n\nSignal: %s", + (nodeName && nodeName[0]) ? nodeName : "unknown", qualityLabel); + } else { + if (this->lastSentNode == NODENUM_BROADCAST) { + snprintf(buf, sizeof(buf), "Message failed to\n#%s", + (channelName && channelName[0]) ? channelName : "unknown"); + } else { + snprintf(buf, sizeof(buf), "DM failed to\n@%s", (nodeName && nodeName[0]) ? nodeName : "unknown"); + } + } + + opts.message = buf; + opts.durationMs = 3000; + graphics::bannerSignalBars = bars; // tell banner renderer how many bars to draw + screen->showOverlayBanner(opts); // this triggers drawNotificationBox() + } + } + } + + return ProcessMessage::CONTINUE; +} + +#endif diff --git a/src/modules/FreeTextModule.cpp b/src/modules/FreeTextModule.cpp new file mode 100644 index 000000000..f441b24df --- /dev/null +++ b/src/modules/FreeTextModule.cpp @@ -0,0 +1,1320 @@ +#include "configuration.h" +#if HAS_SCREEN +#include "Channels.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/emotes.h" +#include "input/SerialKeyboard.h" +#include "modules/CannedMessageModule.h" +#include "modules/FreeTextModule.h" +#include +#include +#include +#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) +#include "graphics/EInkDynamicDisplay.h" +#endif + +static constexpr int32_t kInactivateAfterMs = 20000; + +// Split a message into plain-text and emote-label tokens for mixed rendering. +std::vector> freeTextModule::tokenizeMessageWithEmotes(const char *msg) +{ + std::vector> tokens; + 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 { + 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; + } + } + } + return tokens; +} + +// Render one emote token inline and advance the drawing cursor. +void freeTextModule::renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label) +{ + const graphics::Emote *emote = nullptr; + for (int j = 0; j < graphics::numEmotes; j++) { + if (label == 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; + } +} + +namespace +{ +// ASCII-only lowercase helper to avoid locale-dependent behavior. +char toLowerAscii(char c) +{ + if (c >= 'A' && c <= 'Z') { + return static_cast(c + ('a' - 'A')); + } + return c; +} + +// Small built-in dictionary for lightweight freetext word completion. +// Keep this list compact to limit flash/RAM usage on constrained targets. +static const char *const kFreeTextCompletionWords[] = { + "about", "above", "across", "ack", "after", "again", "ahead", "all", "alive", + "already", "answer", "anyone", "arrived", "asap", "at", "available", "back", "base", + "battery", "because", "before", "behind", "below", "between", "busy", "bye", "call", + "can", "cancel", "cannot", "careful", "channel", "check", "checkin", "clear", "close", + "come", "coming", "confirmed", "contact", "continue", "copy", "current", "danger", "data", + "delayed", "destination", "direct", "done", "down", "east", "emergency", "enroute", "ETA", + "evening", "everyone", "failed", "feedback", "fine", "for", "from", "friendly", "going", + "good", "got", "GPS", "grid", "group", "have", "he", "hear", "hello", + "help", "here", "hey", "high", "hold", "home", "how", "inside", "later", + "left", "listen", "location", "lost", "low", "maintain", "meet", "mesh", "Meshtastic", + "message", "morning", "moving", "near", "nearby", "need", "negative", "net", "network", + "night", "node", "none", "north", "nothing", "now", "offgrid", "offline", "okay", + "online", "open", "out", "outside", "over", "perfect", "ping", "pickup", "please", + "point", "positive", "position", "power", "priority", "proceed", "quick", "quiet", "radio", + "ready", "reading", "receive", "received", "repeat", "reply", "request", "resend", "respond", + "return", "returning", "right", "roger", "route", "running", "safe", "safety", "search", + "secure", "see", "seen", "send", "signal", "soon", "south", "standby", "station", + "status", "still", "stop", "success", "support", "target", "team", "temperature", "test", + "thank", "thanks", "that", "the", "there", "these", "this", "towards", "track", + "traffic", "unable", "understood", "update", "urgent", "vehicle", "visual", "wait", "warning", + "watch", "weather", "welcome", "west", "when", "where", "who", "why", "will", + "with", "work", "yes", "you", "your"}; + +enum CompletionWordForm : uint8_t { + COMPLETION_FORM_NONE = 0, + COMPLETION_FORM_S = 1 << 0, + // Past tense/participle form (regular "-ed" or irregular override). + COMPLETION_FORM_ED = 1 << 1, + COMPLETION_FORM_ING = 1 << 2, +}; + +struct CompletionInflectionStem { + const char *stem; + uint8_t forms; +}; + +// Explicit stems where we want generated inflected forms without duplicating dictionary entries. +static const CompletionInflectionStem kFreeTextInflectionStems[] = { + // Regular inflections. + {"ack", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"answer", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"arrive", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"call", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"cancel", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"check", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"clear", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"close", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"confirm", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"contact", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"continue", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"copy", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"delay", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"direct", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"do", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"help", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"listen", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"maintain", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"message", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"move", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"need", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"open", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"pickup", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"ping", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"point", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"position", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"power", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"proceed", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"receive", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"repeat", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"reply", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"request", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"respond", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"return", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"route", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"search", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"secure", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"signal", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"support", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"target", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"thank", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"test", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"track", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"update", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"wait", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"watch", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"welcome", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"work", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + + // Irregular stems still using generated present/continuous and custom past forms. + {"come", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"go", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"have", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"hear", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"hold", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"meet", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"read", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"resend", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"run", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"see", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"send", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, + {"stop", COMPLETION_FORM_S | COMPLETION_FORM_ED | COMPLETION_FORM_ING}, +}; + +bool isAsciiVowel(char c) +{ + const char lower = toLowerAscii(c); + return lower == 'a' || lower == 'e' || lower == 'i' || lower == 'o' || lower == 'u'; +} + +bool isAsciiConsonant(char c) +{ + const char lower = toLowerAscii(c); + return (lower >= 'a' && lower <= 'z') && !isAsciiVowel(lower); +} + +bool endsWithAscii(const char *word, const char *suffix) +{ + const size_t wordLen = strlen(word); + const size_t suffixLen = strlen(suffix); + if (suffixLen > wordLen) { + return false; + } + return strncmp(word + (wordLen - suffixLen), suffix, suffixLen) == 0; +} + +bool shouldDoubleFinalConsonant(const char *stem) +{ + const size_t len = strlen(stem); + if (len < 3 || len > 4) { + return false; + } + + const char last = toLowerAscii(stem[len - 1]); + if (last == 'w' || last == 'x' || last == 'y') { + return false; + } + + if (len == 3) { + return isAsciiConsonant(stem[0]) && isAsciiVowel(stem[1]) && isAsciiConsonant(stem[2]); + } + + // len == 4: approximate "CCVC" words like stop/chat/plan, but avoid "open". + return isAsciiConsonant(stem[0]) && isAsciiConsonant(stem[1]) && isAsciiVowel(stem[2]) && isAsciiConsonant(stem[3]); +} + +String buildSForm(const char *stem) +{ + if (strcmp(stem, "do") == 0) { + return "does"; + } + if (strcmp(stem, "have") == 0) { + return "has"; + } + + const size_t len = strlen(stem); + if (len == 0) { + return ""; + } + + if (len >= 2) { + const char last = toLowerAscii(stem[len - 1]); + const char prev = toLowerAscii(stem[len - 2]); + if (last == 'y' && !isAsciiVowel(prev)) { + return String(stem).substring(0, len - 1) + "ies"; + } + if (last == 's' || last == 'x' || last == 'z' || last == 'o') { + return String(stem) + "es"; + } + } + + if (endsWithAscii(stem, "ch") || endsWithAscii(stem, "sh")) { + return String(stem) + "es"; + } + + return String(stem) + "s"; +} + +String buildEdForm(const char *stem) +{ + // Small irregular map for common command/messaging verbs. + if (strcmp(stem, "come") == 0) { + return "came"; + } + if (strcmp(stem, "do") == 0) { + return "did"; + } + if (strcmp(stem, "go") == 0) { + return "went"; + } + if (strcmp(stem, "have") == 0) { + return "had"; + } + if (strcmp(stem, "hear") == 0) { + return "heard"; + } + if (strcmp(stem, "hold") == 0) { + return "held"; + } + if (strcmp(stem, "meet") == 0) { + return "met"; + } + if (strcmp(stem, "read") == 0) { + return "read"; + } + if (strcmp(stem, "resend") == 0) { + return "resent"; + } + if (strcmp(stem, "run") == 0) { + return "ran"; + } + if (strcmp(stem, "see") == 0) { + return "saw"; + } + if (strcmp(stem, "send") == 0) { + return "sent"; + } + + const size_t len = strlen(stem); + if (len == 0) { + return ""; + } + if (toLowerAscii(stem[len - 1]) == 'e') { + return String(stem) + "d"; + } + if (len >= 2 && toLowerAscii(stem[len - 1]) == 'y' && !isAsciiVowel(stem[len - 2])) { + return String(stem).substring(0, len - 1) + "ied"; + } + if (shouldDoubleFinalConsonant(stem)) { + String out = stem; + out += stem[len - 1]; + out += "ed"; + return out; + } + return String(stem) + "ed"; +} + +String buildIngForm(const char *stem) +{ + const size_t len = strlen(stem); + if (len == 0) { + return ""; + } + if (len >= 2 && toLowerAscii(stem[len - 2]) == 'i' && toLowerAscii(stem[len - 1]) == 'e') { + return String(stem).substring(0, len - 2) + "ying"; + } + if (toLowerAscii(stem[len - 1]) == 'e' && !(len >= 2 && toLowerAscii(stem[len - 2]) == 'e')) { + return String(stem).substring(0, len - 1) + "ing"; + } + if (shouldDoubleFinalConsonant(stem)) { + String out = stem; + out += stem[len - 1]; + out += "ing"; + return out; + } + return String(stem) + "ing"; +} + +// True when `word` starts with the already-lowercased prefix. +bool startsWithAscii(const char *word, const String &prefixLower) +{ + size_t prefixLen = prefixLower.length(); + if (prefixLen == 0) { + return false; + } + + for (size_t i = 0; i < prefixLen; ++i) { + char wc = word[i]; + if (wc == '\0') { + return false; + } + if (toLowerAscii(wc) != prefixLower[i]) { + return false; + } + } + return true; +} + +// Stable case-insensitive compare used to keep completion ordering deterministic. +int compareCaseInsensitiveAscii(const char *lhs, const char *rhs) +{ + size_t i = 0; + while (lhs[i] != '\0' && rhs[i] != '\0') { + char l = toLowerAscii(lhs[i]); + char r = toLowerAscii(rhs[i]); + if (l < r) { + return -1; + } + if (l > r) { + return 1; + } + ++i; + } + + if (lhs[i] == '\0' && rhs[i] == '\0') { + return strcmp(lhs, rhs); + } + return (lhs[i] == '\0') ? -1 : 1; +} + +// Match helper for already materialized String completions. +bool startsWithLowercasePrefix(const String &word, const String &prefixLower) +{ + if (prefixLower.length() == 0 || word.length() < prefixLower.length()) { + return false; + } + + for (size_t i = 0; i < prefixLower.length(); ++i) { + if (toLowerAscii(word[i]) != prefixLower[i]) { + return false; + } + } + return true; +} +} // namespace + +// Return the current trailing word at cursor (lowercased) for completion lookup. +String CannedMessageModule::getFreeTextPrefix() const +{ + if (this->cursor == 0 || this->cursor > this->freetext.length() || this->cursor != this->freetext.length()) { + return ""; + } + + unsigned int start = this->cursor; + while (start > 0) { + char c = this->freetext[start - 1]; + if (!(c >= 'A' && c <= 'Z') && !(c >= 'a' && c <= 'z')) { + break; + } + --start; + } + + if (start == this->cursor) { + return ""; + } + + String prefix = this->freetext.substring(start, this->cursor); + prefix.toLowerCase(); + return prefix; +} + +// Refresh completion candidates based on current freetext cursor position. +void CannedMessageModule::updateFreeTextCompletion() +{ + String previousSelection = this->freeTextCompletion; + this->freeTextCompletion = ""; + this->freeTextCompletionCount = 0; + this->freeTextCompletionIndex = 0; + if (this->runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) { + return; + } + if (this->freeTextCompletionSuppressed) { + return; + } + + String prefixLower = getFreeTextPrefix(); + if (prefixLower.length() < 2) { + return; + } + + auto insertCandidate = [this, &prefixLower](const String &candidate) { + if (!startsWithLowercasePrefix(candidate, prefixLower)) { + return; + } + + const size_t candidateLen = candidate.length(); + if (candidateLen <= static_cast(prefixLower.length())) { + return; + } + + for (uint8_t i = 0; i < this->freeTextCompletionCount; ++i) { + if (this->freeTextCompletions[i] == candidate) { + return; + } + } + + uint8_t insertAt = this->freeTextCompletionCount; + while (insertAt > 0) { + const String &existing = this->freeTextCompletions[insertAt - 1]; + const size_t existingLen = existing.length(); + const int lexicalCompare = compareCaseInsensitiveAscii(candidate.c_str(), existing.c_str()); + if (candidateLen > existingLen || (candidateLen == existingLen && lexicalCompare >= 0)) { + break; + } + --insertAt; + } + + if (insertAt >= maxFreeTextCompletions) { + return; + } + + if (this->freeTextCompletionCount < maxFreeTextCompletions) { + this->freeTextCompletionCount++; + } + for (int j = static_cast(this->freeTextCompletionCount) - 1; j > static_cast(insertAt); --j) { + this->freeTextCompletions[j] = this->freeTextCompletions[j - 1]; + } + this->freeTextCompletions[insertAt] = candidate; + }; + + for (size_t i = 0; i < (sizeof(kFreeTextCompletionWords) / sizeof(kFreeTextCompletionWords[0])); ++i) { + const char *candidate = kFreeTextCompletionWords[i]; + if (!startsWithAscii(candidate, prefixLower)) { + continue; + } + insertCandidate(candidate); + } + + for (size_t i = 0; i < (sizeof(kFreeTextInflectionStems) / sizeof(kFreeTextInflectionStems[0])); ++i) { + const CompletionInflectionStem &entry = kFreeTextInflectionStems[i]; + insertCandidate(entry.stem); + if (entry.forms & COMPLETION_FORM_S) { + insertCandidate(buildSForm(entry.stem)); + } + if (entry.forms & COMPLETION_FORM_ED) { + insertCandidate(buildEdForm(entry.stem)); + } + if (entry.forms & COMPLETION_FORM_ING) { + insertCandidate(buildIngForm(entry.stem)); + } + } + + if (this->freeTextCompletionCount > 0) { + uint8_t selectedIndex = 0; + for (uint8_t i = 0; i < this->freeTextCompletionCount; ++i) { + if (this->freeTextCompletions[i] == previousSelection) { + selectedIndex = i; + break; + } + } + this->freeTextCompletionIndex = selectedIndex; + this->freeTextCompletion = this->freeTextCompletions[this->freeTextCompletionIndex]; + } +} + +// Cycle through available completions +bool CannedMessageModule::cycleFreeTextCompletion(int8_t step) +{ + if (this->freeTextCompletionCount < 2) { + return false; + } + + int next = static_cast(this->freeTextCompletionIndex) + static_cast(step); + while (next < 0) { + next += this->freeTextCompletionCount; + } + while (next >= this->freeTextCompletionCount) { + next -= this->freeTextCompletionCount; + } + this->freeTextCompletionIndex = static_cast(next); + this->freeTextCompletion = this->freeTextCompletions[this->freeTextCompletionIndex]; + return true; +} + +// Replace current word with selected completion and optionally append a space. +bool CannedMessageModule::acceptFreeTextCompletion(bool appendSpace) +{ + if (this->freeTextCompletion.length() == 0) { + return false; + } + + String prefixLower = getFreeTextPrefix(); + if (prefixLower.length() < 2 || this->freeTextCompletion.length() <= prefixLower.length()) { + return false; + } + + unsigned int start = this->cursor - prefixLower.length(); + String acceptedWord = this->freeTextCompletion; + if (start < this->cursor && acceptedWord.length() > 0) { + char typedFirstChar = this->freetext[start]; + if (typedFirstChar >= 'A' && typedFirstChar <= 'Z') { + char acceptedFirstChar = acceptedWord[0]; + if (acceptedFirstChar >= 'a' && acceptedFirstChar <= 'z') { + acceptedWord[0] = static_cast(acceptedFirstChar - ('a' - 'A')); + } + } + } + + this->freetext = this->freetext.substring(0, start) + acceptedWord + this->freetext.substring(this->cursor); + this->cursor = start + acceptedWord.length(); + + const uint16_t maxChars = 200 - (moduleConfig.canned_message.send_bell ? 1 : 0); + if (appendSpace && this->cursor < maxChars) { + this->freetext = this->freetext.substring(0, this->cursor) + " " + this->freetext.substring(this->cursor); + this->cursor++; + } + if (this->freetext.length() > maxChars) { + this->freetext = this->freetext.substring(0, maxChars); + this->cursor = maxChars; + } + + // Treat a just-selected completion as final until the user edits again. + this->freeTextCompletionSuppressed = true; + this->freeTextCompletion = ""; + this->freeTextCompletionCount = 0; + this->freeTextCompletionIndex = 0; + return true; +} + +// Draw horizontally scrollable completion chips under the freetext input area. +void CannedMessageModule::drawFreeTextCompletionRow(OLEDDisplay *display, int16_t x, int16_t viewportTop, int16_t viewportBottom, + int rowHeight, int linesCount, int scrollRows, const String &completionPrefix) +{ + const int completionRowY = viewportTop + ((linesCount - scrollRows) * rowHeight); + if (completionRowY < (viewportTop - rowHeight) || completionRowY > viewportBottom) { + return; + } + + const int spaceWidth = display->getStringWidth(" "); + const int separatorWidth = spaceWidth * 2; + const int viewportWidth = display->getWidth(); + + struct ChoiceLayout { + uint8_t idx; + String word; + int startX; + int width; + }; + + std::vector choices; + int runningX = 0; + bool hasChoices = false; + int selectedStart = 0; + int selectedEnd = 0; + + for (uint8_t i = 0; i < this->freeTextCompletionCount; ++i) { + const String &candidate = this->freeTextCompletions[i]; + if (!startsWithLowercasePrefix(candidate, completionPrefix) || candidate.length() <= completionPrefix.length()) { + continue; + } + + if (hasChoices) { + runningX += separatorWidth; + } + + int tokenWidth = display->getStringWidth(candidate); + choices.push_back({i, candidate, runningX, tokenWidth}); + if (i == this->freeTextCompletionIndex) { + selectedStart = runningX; + selectedEnd = runningX + tokenWidth; + } + + runningX += tokenWidth; + hasChoices = true; + } + + int choiceScrollX = 0; + if (runningX > viewportWidth) { + choiceScrollX = std::max(0, selectedEnd - viewportWidth); + if (selectedStart < choiceScrollX) { + choiceScrollX = selectedStart; + } + const int maxScrollX = std::max(0, runningX - viewportWidth); + if (choiceScrollX > maxScrollX) { + choiceScrollX = maxScrollX; + } + } + + for (const auto &choice : choices) { + int drawX = x + (choice.startX - choiceScrollX); + if ((drawX + choice.width) < x || drawX > (x + viewportWidth)) { + continue; + } + + if (choice.idx == this->freeTextCompletionIndex) { +#ifdef USE_EINK + display->drawRect(drawX - 1, completionRowY, choice.width + 2, FONT_HEIGHT_SMALL - 1); + display->drawString(drawX, completionRowY, choice.word); +#else + display->fillRect(drawX - 1, completionRowY, choice.width + 2, FONT_HEIGHT_SMALL); + display->setColor(BLACK); + display->drawString(drawX, completionRowY, choice.word); + display->setColor(WHITE); +#endif + } else { + display->drawString(drawX, completionRowY, choice.word); + } + } +} + +// Insert visual cursor (and optional completion suffix hint) into rendered text. +String CannedMessageModule::drawWithCursor(String text, int cursor) +{ + if (cursor < 0) { + cursor = 0; + } + if (cursor > static_cast(text.length())) { + cursor = text.length(); + } + + String completionSuffix = ""; + if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor == this->freetext.length() && + this->freeTextCompletionCount > 0 && this->freeTextCompletionIndex < this->freeTextCompletionCount) { + String prefixLower = this->getFreeTextPrefix(); + const String &candidate = this->freeTextCompletions[this->freeTextCompletionIndex]; + if (prefixLower.length() >= 2 && startsWithAscii(candidate.c_str(), prefixLower) && + candidate.length() > prefixLower.length()) { + completionSuffix = candidate.substring(prefixLower.length()); + } + } + + return text.substring(0, cursor) + "|" + completionSuffix + text.substring(cursor); +} + +// If idle screen receives printable key, jump directly into freetext mode. +bool CannedMessageModule::tryStartFreeTextFromInactive(const InputEvent *event) +{ + if (event->kbchar < 32 || event->kbchar > 126) { + return false; + } + + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + requestFocus(); + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + + // Process the initiating key as the first freetext character. + return handleFreeTextInput(event); +} + +// Apply pending freetext payload action (typing/edit/move) and refresh UI state. +int32_t CannedMessageModule::runFreeTextState(UIFrameEvent &e) +{ + switch (this->payload) { + case INPUT_BROKER_LEFT: + if (this->cursor == this->freetext.length() && this->freeTextCompletionCount > 1) { + this->cycleFreeTextCompletion(-1); + } else if (this->cursor > 0) { + this->cursor--; + } + break; + case INPUT_BROKER_RIGHT: + if (this->cursor < this->freetext.length()) { + this->cursor++; + } else if (this->freeTextCompletionCount > 1) { + this->cycleFreeTextCompletion(1); + } else if (this->freeTextCompletionCount > 0) { + this->acceptFreeTextCompletion(true); + } + break; + default: + break; + } + + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + switch (this->payload) { + case 0x08: // backspace + this->freeTextCompletionSuppressed = false; + if (this->freetext.length() > 0 && 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--; + } + break; + case INPUT_BROKER_MSG_TAB: + return 0; + case INPUT_BROKER_LEFT: + case INPUT_BROKER_RIGHT: + break; + default: + if (this->payload >= 32 && this->payload <= 126) { + this->freeTextCompletionSuppressed = false; + requestFocus(); + if (this->cursor == this->freetext.length()) { + this->freetext += static_cast(this->payload); + this->cursor++; + } else { + this->freetext = this->freetext.substring(0, this->cursor) + static_cast(this->payload) + + this->freetext.substring(this->cursor); + this->cursor++; + } + const uint16_t maxChars = 200 - (moduleConfig.canned_message.send_bell ? 1 : 0); + if (this->freetext.length() > maxChars) { + this->cursor = maxChars; + this->freetext = this->freetext.substring(0, maxChars); + } + } + break; + } + + this->updateFreeTextCompletion(); + this->lastTouchMillis = millis(); + this->notifyObservers(&e); + return kInactivateAfterMs; +} + +// Route physical/touch input while in freetext mode. +bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) +{ + if (runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) { + return false; + } + +#if defined(USE_VIRTUAL_KEYBOARD) + if (event->inputEvent == INPUT_BROKER_LEFT) { + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + freetext = ""; + cursor = 0; + payload = 0; + currentMessageIndex = -1; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + + 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; + } else if (keyTapped == "↵") { + runState = CANNED_MESSAGE_RUN_STATE_ACTION_SELECT; + 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; + } + } +#endif + + if (event->kbchar == INPUT_BROKER_MSG_EMOTE_LIST) { + runState = CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER; + requestFocus(); + screen->forceDisplay(); + return true; + } + + bool isSelect = isSelectEvent(event); + if (isSelect) { + if (this->cursor == this->freetext.length() && this->freeTextCompletionCount > 0 && + this->acceptFreeTextCompletion(true)) { + this->payload = 0; + this->lastTouchMillis = millis(); + requestFocus(); + runOnce(); + return true; + } + + 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; + } + 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; + } + + if (event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() > 0) { + payload = 0x08; + lastTouchMillis = millis(); + requestFocus(); + runOnce(); + return true; + } + + if (event->inputEvent == INPUT_BROKER_LEFT) { + payload = INPUT_BROKER_LEFT; + lastTouchMillis = millis(); + requestFocus(); + runOnce(); + return true; + } + if (event->inputEvent == INPUT_BROKER_RIGHT) { + payload = INPUT_BROKER_RIGHT; + lastTouchMillis = millis(); + requestFocus(); + runOnce(); + return true; + } + + if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG || + (event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() == 0)) { + runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + freetext = ""; + cursor = 0; + payload = 0; + currentMessageIndex = -1; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + notifyObservers(&e); + screen->forceDisplay(); + return true; + } + + if (event->kbchar == INPUT_BROKER_MSG_TAB) { + return handleTabSwitch(event); + } + + if (event->kbchar >= 32 && event->kbchar <= 126) { + payload = event->kbchar; + lastTouchMillis = millis(); + runOnce(); + return true; + } + + return false; +} + +// Navigate/select emotes, then insert selected emote into freetext. +int CannedMessageModule::handleEmotePickerInput(const InputEvent *event) +{ + int numEmotes = graphics::numEmotes; + + 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; + } + } + + if (isUp && emotePickerIndex > 0) { + emotePickerIndex--; + screen->forceDisplay(); + return 1; + } + if (isDown && emotePickerIndex < numEmotes - 1) { + emotePickerIndex++; + screen->forceDisplay(); + return 1; + } + + if (isSelect) { + String label = graphics::emotes[emotePickerIndex].label; + String emoteInsert = label; + 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; + } + + if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) { + runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + screen->forceDisplay(); + return 1; + } + + return 0; +} + +// Draw freetext composer UI (header, live text, and completion row/keyboard). +void CannedMessageModule::drawFreeTextScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, char *buffer) +{ + requestFocus(); +#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) + EInkDynamicDisplay *einkDisplay = static_cast(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. + uint16_t charsLeft = + meshtastic_Constants_DATA_PAYLOAD_LEN - this->freetext.length() - (moduleConfig.canned_message.send_bell ? 1 : 0); + snprintf(buffer, 50, "%d left", charsLeft); + display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer); + +#if INPUTBROKER_SERIAL_TYPE == 1 + // Chatter Modifier key mode label (right side). + { + uint8_t mode = globalSerialKeyboard ? globalSerialKeyboard->getShift() : 0; + const char *label = (mode == 0) ? "a" : (mode == 1) ? "A" : "#"; + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const int16_t th = FONT_HEIGHT_SMALL; + const int16_t tw = display->getStringWidth(label); + const int16_t padX = 3; + const int16_t padY = 2; + const int16_t r = 3; + + const int16_t bw = tw + padX * 2; + const int16_t bh = th + padY * 2; + + const int16_t bx = x + display->getWidth() - bw - 2; + const int16_t by = y + display->getHeight() - bh - 2; + + display->setColor(WHITE); + display->fillRect(bx + r, by, bw - r * 2, bh); + display->fillRect(bx, by + r, r, bh - r * 2); + display->fillRect(bx + bw - r, by + r, r, bh - r * 2); + display->fillCircle(bx + r, by + r, r); + display->fillCircle(bx + bw - r - 1, by + r, r); + display->fillCircle(bx + r, by + bh - r - 1, r); + display->fillCircle(bx + bw - r - 1, by + bh - r - 1, r); + + display->setColor(BLACK); + display->drawString(bx + padX, by + padY, label); + } + + // Left-side destination hint box ("Dest: Shift + <"). + { + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const char *label = "Dest: Shift + "; + int16_t labelW = display->getStringWidth(label); + + // Triangle size visually matches glyph height, not full line height. + const int triH = FONT_HEIGHT_SMALL - 3; + const int triW = triH * 0.7; + + const int16_t padX = 3; + const int16_t padY = 2; + const int16_t r = 3; + + const int16_t bw = labelW + triW + padX * 2 + 2; + const int16_t bh = FONT_HEIGHT_SMALL + padY * 2; + + const int16_t bx = x + 2; + const int16_t by = y + display->getHeight() - bh - 2; + + // Rounded white box. + display->setColor(WHITE); + display->fillRect(bx + r, by, bw - (r * 2), bh); + display->fillRect(bx, by + r, r, bh - (r * 2)); + display->fillRect(bx + bw - r, by + r, r, bh - (r * 2)); + display->fillCircle(bx + r, by + r, r); + display->fillCircle(bx + bw - r - 1, by + r, r); + display->fillCircle(bx + r, by + bh - r - 1, r); + display->fillCircle(bx + bw - r - 1, by + bh - r - 1, r); + + // Draw text. + display->setColor(BLACK); + display->drawString(bx + padX, by + padY, label); + + // Center triangle on text baseline. + int16_t tx = bx + padX + labelW; + int16_t ty = by + padY + (FONT_HEIGHT_SMALL / 2) - (triH / 2) - 1; + + display->fillTriangle(tx + triW, ty, tx, ty + triH / 2, tx + triW, ty + triH); + } +#endif + + // Draw freetext input with multi-emote support and proper line wrapping. + display->setColor(WHITE); + { + const int inputTopOffset = -3; + int inputY = y + FONT_HEIGHT_SMALL + inputTopOffset; + String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor); + + // Tokenize input into (isEmote, token) pairs. + const char *msg = msgWithCursor.c_str(); + std::vector> tokens = freeTextModule::tokenizeMessageWithEmotes(msg); + + // Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed). + std::vector>> lines; + std::vector> currentLine; + int lineWidth = 0; + int maxWidth = display->getWidth(); + for (auto &token : tokens) { + if (token.first) { + 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; + int pos = 0; + while (pos < static_cast(text.length())) { + int spacePos = text.indexOf(' ', pos); + int endPos = (spacePos == -1) ? text.length() : spacePos + 1; // Include space. + String word = text.substring(pos, endPos); + int wordWidth = display->getStringWidth(word); + + if (lineWidth + wordWidth > maxWidth && lineWidth > 0) { + lines.push_back(currentLine); + currentLine.clear(); + lineWidth = 0; + } + if (wordWidth > maxWidth) { + uint16_t charPos = 0; + while (charPos < word.length()) { + String oneChar = word.substring(charPos, charPos + 1); + int charWidth = display->getStringWidth(oneChar); + if (lineWidth + charWidth > maxWidth && lineWidth > 0) { + lines.push_back(currentLine); + currentLine.clear(); + lineWidth = 0; + } + currentLine.push_back({false, oneChar}); + lineWidth += charWidth; + charPos++; + } + } else { + currentLine.push_back({false, word}); + lineWidth += wordWidth; + } + pos = endPos; + } + } + } + if (!currentLine.empty()) + lines.push_back(currentLine); + + const int rowHeight = std::max(8, FONT_HEIGHT_SMALL - 3); + const int viewportTop = inputY; + const int viewportBottom = y + display->getHeight(); + const int viewportHeight = std::max(1, viewportBottom - viewportTop); + const int viewportRows = std::max(1, viewportHeight / rowHeight); + + int cursorRow = static_cast(lines.size()) - 1; + if (cursorRow < 0) { + cursorRow = 0; + } + if (this->cursor < this->freetext.length()) { + for (int lineIdx = 0; lineIdx < static_cast(lines.size()); ++lineIdx) { + bool hasCursorMarker = false; + for (const auto &token : lines[lineIdx]) { + if (!token.first && token.second.indexOf('_') >= 0) { + hasCursorMarker = true; + break; + } + } + if (hasCursorMarker) { + cursorRow = lineIdx; + break; + } + } + } + + String completionPrefix = this->getFreeTextPrefix(); + const bool showCompletionRow = + (this->cursor == this->freetext.length() && this->freeTextCompletionCount > 1 && completionPrefix.length() >= 2); + const int totalRows = static_cast(lines.size()) + (showCompletionRow ? 1 : 0); + int scrollRows = std::max(0, totalRows - viewportRows); + int targetRow = cursorRow + (showCompletionRow ? 1 : 0); + if (targetRow < scrollRows) { + scrollRows = targetRow; + } + if (targetRow >= (scrollRows + viewportRows)) { + scrollRows = targetRow - viewportRows + 1; + } + if (scrollRows < 0) { + scrollRows = 0; + } + + // Draw wrapped text rows with vertical scrolling. + for (int rowIdx = 0; rowIdx < static_cast(lines.size()); ++rowIdx) { + int yLine = viewportTop + ((rowIdx - scrollRows) * rowHeight); + if (yLine < viewportTop || (yLine + rowHeight) > (viewportBottom + rowHeight)) { + continue; + } + + int nextX = x; + for (const auto &token : lines[rowIdx]) { + if (token.first) { + freeTextModule::renderEmote(display, nextX, yLine, rowHeight, token.second); + } else { + display->drawString(nextX, yLine, token.second); + nextX += display->getStringWidth(token.second); + } + } + } + + if (showCompletionRow) { + this->drawFreeTextCompletionRow(display, x, viewportTop, viewportBottom, rowHeight, static_cast(lines.size()), + scrollRows, completionPrefix); + } + } +#endif +} + +// Draw scrollable emote picker list. +void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const int headerFontHeight = FONT_HEIGHT_SMALL; + const int headerMargin = 2; + const int labelGap = 6; + const int bitmapGapX = 4; + + 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; + int headerY = y; + int listTop = headerY + headerFontHeight + headerMargin; + int _visibleRows = (display->getHeight() - listTop - 2) / rowHeight; + int numEmotes = graphics::numEmotes; + + this->visibleRows = _visibleRows; + + if (emotePickerIndex < 0) { + emotePickerIndex = 0; + } + if (emotePickerIndex >= numEmotes) { + emotePickerIndex = numEmotes - 1; + } + + int topIndex = emotePickerIndex - _visibleRows / 2; + if (topIndex < 0) { + topIndex = 0; + } + if (topIndex > numEmotes - _visibleRows) { + topIndex = std::max(0, numEmotes - _visibleRows); + } + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(display->getWidth() / 2, headerY, "Select Emote"); + 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; + + if (emoteIdx == emotePickerIndex) { + display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2); + display->setColor(BLACK); + } + + int emoteY = rowY + 1; + display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.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); + } + } + + 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); + } +} + +#endif diff --git a/src/modules/FreeTextModule.h b/src/modules/FreeTextModule.h new file mode 100644 index 000000000..97fdeda8a --- /dev/null +++ b/src/modules/FreeTextModule.h @@ -0,0 +1,16 @@ +#pragma once +#if HAS_SCREEN + +#include "configuration.h" +#include +#include + +class OLEDDisplay; + +namespace freeTextModule +{ +std::vector> tokenizeMessageWithEmotes(const char *msg); +void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label); +} // namespace freeTextModule + +#endif diff --git a/src/modules/TextPrediction.cpp b/src/modules/TextPrediction.cpp deleted file mode 100644 index 5ff4f95b5..000000000 --- a/src/modules/TextPrediction.cpp +++ /dev/null @@ -1,353 +0,0 @@ -#include "configuration.h" -#if HAS_SCREEN -#include "graphics/ScreenFonts.h" -#include "modules/CannedMessageModule.h" -#include - -namespace -{ -char toLowerAscii(char c) -{ - if (c >= 'A' && c <= 'Z') { - return static_cast(c + ('a' - 'A')); - } - return c; -} - -// Small built-in dictionary for lightweight freetext word prediction. -// Keep this list compact to limit flash/RAM usage on constrained targets. -static const char *const kFreeTextPredictWords[] = { - "about", "above", "across", "ack", "after", "again", "ahead", "all", "alive", - "already", "answer", "anyone", "arrived", "asap", "at", "available", "back", "base", - "battery", "because", "before", "behind", "below", "between", "busy", "bye", "call", - "can", "cancel", "cannot", "careful", "channel", "check", "checkin", "clear", "close", - "come", "coming", "confirmed", "contact", "continue", "copy", "current", "danger", "data", - "delayed", "destination", "direct", "done", "down", "east", "emergency", "enroute", "ETA", - "evening", "everyone", "failed", "feedback", "fine", "for", "from", "friendly", "going", - "good", "got", "GPS", "grid", "group", "have", "he", "hear", "hello", - "help", "here", "hey", "high", "hold", "home", "how", "inside", "later", - "left", "listen", "location", "lost", "low", "maintain", "meet", "mesh", "Meshtastic", - "message", "morning", "moving", "near", "nearby", "need", "negative", "net", "network", - "night", "node", "none", "north", "nothing", "now", "offgrid", "offline", "okay", - "online", "open", "out", "outside", "over", "perfect", "ping", "pickup", "please", - "point", "positive", "position", "power", "priority", "proceed", "quick", "quiet", "radio", - "ready", "reading", "receive", "received", "repeat", "reply", "request", "resend", "respond", - "return", "returning", "right", "roger", "route", "running", "safe", "safety", "search", - "secure", "see", "seen", "send", "signal", "soon", "south", "standby", "station", - "status", "still", "stop", "success", "support", "target", "team", "temperature", "test", - "thank", "thanks", "that", "the", "there", "these", "this", "towards", "track", - "traffic", "unable", "understood", "update", "urgent", "vehicle", "visual", "wait", "warning", - "watch", "weather", "welcome", "west", "when", "where", "who", "why", "will", - "with", "work", "yes", "you", "your"}; - -bool startsWithAscii(const char *word, const String &prefixLower) -{ - size_t prefixLen = prefixLower.length(); - if (prefixLen == 0) { - return false; - } - - for (size_t i = 0; i < prefixLen; ++i) { - char wc = word[i]; - if (wc == '\0') { - return false; - } - if (toLowerAscii(wc) != prefixLower[i]) { - return false; - } - } - return true; -} - -int compareCaseInsensitiveAscii(const char *lhs, const char *rhs) -{ - size_t i = 0; - while (lhs[i] != '\0' && rhs[i] != '\0') { - char l = toLowerAscii(lhs[i]); - char r = toLowerAscii(rhs[i]); - if (l < r) { - return -1; - } - if (l > r) { - return 1; - } - ++i; - } - - if (lhs[i] == '\0' && rhs[i] == '\0') { - return strcmp(lhs, rhs); - } - return (lhs[i] == '\0') ? -1 : 1; -} - -bool startsWithLowercasePrefix(const String &word, const String &prefixLower) -{ - if (prefixLower.length() == 0 || word.length() < prefixLower.length()) { - return false; - } - - for (size_t i = 0; i < prefixLower.length(); ++i) { - if (toLowerAscii(word[i]) != prefixLower[i]) { - return false; - } - } - return true; -} -} // namespace - -String CannedMessageModule::getFreeTextPrefix() const -{ - if (this->cursor == 0 || this->cursor > this->freetext.length() || this->cursor != this->freetext.length()) { - return ""; - } - - unsigned int start = this->cursor; - while (start > 0) { - char c = this->freetext[start - 1]; - if (!(c >= 'A' && c <= 'Z') && !(c >= 'a' && c <= 'z')) { - break; - } - --start; - } - - if (start == this->cursor) { - return ""; - } - - String prefix = this->freetext.substring(start, this->cursor); - prefix.toLowerCase(); - return prefix; -} - -void CannedMessageModule::updateFreeTextPrediction() -{ - String previousSelection = this->freeTextPrediction; - this->freeTextPrediction = ""; - this->freeTextPredictionCount = 0; - this->freeTextPredictionIndex = 0; - if (this->runState != CANNED_MESSAGE_RUN_STATE_FREETEXT) { - return; - } - if (this->freeTextPredictionSuppressed) { - return; - } - - String prefixLower = getFreeTextPrefix(); - if (prefixLower.length() < 2) { - return; - } - - for (size_t i = 0; i < (sizeof(kFreeTextPredictWords) / sizeof(kFreeTextPredictWords[0])); ++i) { - const char *candidate = kFreeTextPredictWords[i]; - if (!startsWithAscii(candidate, prefixLower)) { - continue; - } - - const size_t candidateLen = strlen(candidate); - if (candidateLen <= static_cast(prefixLower.length())) { - continue; - } - - uint8_t insertAt = this->freeTextPredictionCount; - while (insertAt > 0) { - const String &existing = this->freeTextPredictions[insertAt - 1]; - const size_t existingLen = existing.length(); - const int lexicalCompare = compareCaseInsensitiveAscii(candidate, existing.c_str()); - if (candidateLen > existingLen || (candidateLen == existingLen && lexicalCompare >= 0)) { - break; - } - --insertAt; - } - - if (insertAt >= maxFreeTextPredictions) { - continue; - } - - if (this->freeTextPredictionCount < maxFreeTextPredictions) { - this->freeTextPredictionCount++; - } - for (int j = static_cast(this->freeTextPredictionCount) - 1; j > static_cast(insertAt); --j) { - this->freeTextPredictions[j] = this->freeTextPredictions[j - 1]; - } - this->freeTextPredictions[insertAt] = candidate; - } - - if (this->freeTextPredictionCount > 0) { - uint8_t selectedIndex = 0; - for (uint8_t i = 0; i < this->freeTextPredictionCount; ++i) { - if (this->freeTextPredictions[i] == previousSelection) { - selectedIndex = i; - break; - } - } - this->freeTextPredictionIndex = selectedIndex; - this->freeTextPrediction = this->freeTextPredictions[this->freeTextPredictionIndex]; - } -} - -bool CannedMessageModule::cycleFreeTextPrediction(int8_t step) -{ - if (this->freeTextPredictionCount < 2) { - return false; - } - - int next = static_cast(this->freeTextPredictionIndex) + static_cast(step); - while (next < 0) { - next += this->freeTextPredictionCount; - } - while (next >= this->freeTextPredictionCount) { - next -= this->freeTextPredictionCount; - } - this->freeTextPredictionIndex = static_cast(next); - this->freeTextPrediction = this->freeTextPredictions[this->freeTextPredictionIndex]; - return true; -} - -bool CannedMessageModule::acceptFreeTextPrediction(bool appendSpace) -{ - if (this->freeTextPrediction.length() == 0) { - return false; - } - - String prefixLower = getFreeTextPrefix(); - if (prefixLower.length() < 2 || this->freeTextPrediction.length() <= prefixLower.length()) { - return false; - } - - unsigned int start = this->cursor - prefixLower.length(); - String acceptedWord = this->freeTextPrediction; - if (start < this->cursor && acceptedWord.length() > 0) { - char typedFirstChar = this->freetext[start]; - if (typedFirstChar >= 'A' && typedFirstChar <= 'Z') { - char acceptedFirstChar = acceptedWord[0]; - if (acceptedFirstChar >= 'a' && acceptedFirstChar <= 'z') { - acceptedWord[0] = static_cast(acceptedFirstChar - ('a' - 'A')); - } - } - } - - this->freetext = this->freetext.substring(0, start) + acceptedWord + this->freetext.substring(this->cursor); - this->cursor = start + acceptedWord.length(); - - const uint16_t maxChars = 200 - (moduleConfig.canned_message.send_bell ? 1 : 0); - if (appendSpace && this->cursor < maxChars) { - this->freetext = this->freetext.substring(0, this->cursor) + " " + this->freetext.substring(this->cursor); - this->cursor++; - } - if (this->freetext.length() > maxChars) { - this->freetext = this->freetext.substring(0, maxChars); - this->cursor = maxChars; - } - - // Treat a just-selected prediction as final until the user edits again. - this->freeTextPredictionSuppressed = true; - this->freeTextPrediction = ""; - this->freeTextPredictionCount = 0; - this->freeTextPredictionIndex = 0; - return true; -} - -void CannedMessageModule::drawFreeTextPredictionRow(OLEDDisplay *display, int16_t x, int16_t viewportTop, int16_t viewportBottom, - int rowHeight, int linesCount, int scrollRows, const String &predictionPrefix) -{ - const int predictionRowY = viewportTop + ((linesCount - scrollRows) * rowHeight); - if (predictionRowY < (viewportTop - rowHeight) || predictionRowY > viewportBottom) { - return; - } - - const int spaceWidth = display->getStringWidth(" "); - const int separatorWidth = spaceWidth * 2; - const int viewportWidth = display->getWidth(); - - struct ChoiceLayout { - uint8_t idx; - String word; - int startX; - int width; - }; - - std::vector choices; - int runningX = 0; - bool hasChoices = false; - int selectedStart = 0; - int selectedEnd = 0; - - for (uint8_t i = 0; i < this->freeTextPredictionCount; ++i) { - const String &candidate = this->freeTextPredictions[i]; - if (!startsWithLowercasePrefix(candidate, predictionPrefix) || candidate.length() <= predictionPrefix.length()) { - continue; - } - - if (hasChoices) { - runningX += separatorWidth; - } - - int tokenWidth = display->getStringWidth(candidate); - choices.push_back({i, candidate, runningX, tokenWidth}); - if (i == this->freeTextPredictionIndex) { - selectedStart = runningX; - selectedEnd = runningX + tokenWidth; - } - - runningX += tokenWidth; - hasChoices = true; - } - - int choiceScrollX = 0; - if (runningX > viewportWidth) { - choiceScrollX = std::max(0, selectedEnd - viewportWidth); - if (selectedStart < choiceScrollX) { - choiceScrollX = selectedStart; - } - const int maxScrollX = std::max(0, runningX - viewportWidth); - if (choiceScrollX > maxScrollX) { - choiceScrollX = maxScrollX; - } - } - - for (const auto &choice : choices) { - int drawX = x + (choice.startX - choiceScrollX); - if ((drawX + choice.width) < x || drawX > (x + viewportWidth)) { - continue; - } - - if (choice.idx == this->freeTextPredictionIndex) { -#ifdef USE_EINK - display->drawRect(drawX - 1, predictionRowY, choice.width + 2, FONT_HEIGHT_SMALL - 1); - display->drawString(drawX, predictionRowY, choice.word); -#else - display->fillRect(drawX - 1, predictionRowY, choice.width + 2, FONT_HEIGHT_SMALL); - display->setColor(BLACK); - display->drawString(drawX, predictionRowY, choice.word); - display->setColor(WHITE); -#endif - } else { - display->drawString(drawX, predictionRowY, choice.word); - } - } -} - -String CannedMessageModule::drawWithCursor(String text, int cursor) -{ - if (cursor < 0) { - cursor = 0; - } - if (cursor > static_cast(text.length())) { - cursor = text.length(); - } - - String completionSuffix = ""; - if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT && this->cursor == this->freetext.length() && - this->freeTextPredictionCount > 0 && this->freeTextPredictionIndex < this->freeTextPredictionCount) { - String prefixLower = this->getFreeTextPrefix(); - const String &candidate = this->freeTextPredictions[this->freeTextPredictionIndex]; - if (prefixLower.length() >= 2 && startsWithAscii(candidate.c_str(), prefixLower) && - candidate.length() > prefixLower.length()) { - completionSuffix = candidate.substring(prefixLower.length()); - } - } - - return text.substring(0, cursor) + "|" + completionSuffix + text.substring(cursor); -} - -#endif