From 2b03d012f2991327a6e5d7def15bdcae48c1f672 Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:51:52 -0500 Subject: [PATCH] Text Prediction --- src/modules/CannedMessageModule.cpp | 112 +++++++-- src/modules/CannedMessageModule.h | 12 + src/modules/TextPrediction.cpp | 353 ++++++++++++++++++++++++++++ 3 files changed, 456 insertions(+), 21 deletions(-) create mode 100644 src/modules/TextPrediction.cpp diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index c7eb1b15b..fea27661c 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -866,6 +866,17 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event) // 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) @@ -1291,13 +1302,25 @@ int32_t CannedMessageModule::runOnce() } 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 && this->cursor > 0) { - this->cursor--; + 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 && this->cursor < this->freetext.length()) { - this->cursor++; + 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: @@ -1307,6 +1330,7 @@ int32_t CannedMessageModule::runOnce() 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()) { @@ -1328,14 +1352,16 @@ int32_t CannedMessageModule::runOnce() 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 += (char)this->payload; + this->freetext += static_cast(this->payload); + this->cursor++; } else { - this->freetext = this->freetext.substring(0, this->cursor) + (char)this->payload + + this->freetext = this->freetext.substring(0, this->cursor) + static_cast(this->payload) + this->freetext.substring(this->cursor); + this->cursor++; } - this->cursor++; const uint16_t maxChars = 200 - (moduleConfig.canned_message.send_bell ? 1 : 0); if (this->freetext.length() > maxChars) { this->cursor = maxChars; @@ -1344,6 +1370,7 @@ int32_t CannedMessageModule::runOnce() } break; } + this->updateFreeTextPrediction(); } this->lastTouchMillis = millis(); this->notifyObservers(&e); @@ -1993,7 +2020,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st // Draw Free Text input with multi-emote support and proper line wrapping display->setColor(WHITE); { - int inputY = 0 + y + FONT_HEIGHT_SMALL; + 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 @@ -2064,21 +2092,69 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st if (!currentLine.empty()) lines.push_back(currentLine); - // Draw lines with emotes - int rowHeight = FONT_HEIGHT_SMALL; - int yLine = inputY; - for (const auto &line : lines) { + 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 : line) { + for (const auto &token : lines[rowIdx]) { if (token.first) { - // Emote rendering centralized in helper renderEmote(display, nextX, yLine, rowHeight, token.second); } else { display->drawString(nextX, yLine, token.second); nextX += display->getStringWidth(token.second); } } - yLine += rowHeight; + } + + if (showPredictionRow) { + this->drawFreeTextPredictionRow(display, x, viewportTop, viewportBottom, rowHeight, + static_cast(lines.size()), scrollRows, predictionPrefix); } } #endif @@ -2455,10 +2531,4 @@ void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_ } } -String CannedMessageModule::drawWithCursor(String text, int cursor) -{ - String result = text.substring(0, cursor) + "_" + text.substring(cursor); - return result; -} - #endif diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index 65715dd22..6c6466dac 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -148,6 +148,12 @@ class CannedMessageModule : public SinglePortModule, public Observable + +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