Compare commits
9
Commits
crowpanelV2
...
Freetext
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6457a7ab9e | ||
|
|
c27ad2349b | ||
|
|
e206f14f37 | ||
|
|
1e7228f667 | ||
|
|
f6bf33d61d | ||
|
|
951b812693 | ||
|
|
2632122495 | ||
|
|
96e9041943 | ||
|
|
2b03d012f2 |
@@ -12,6 +12,7 @@
|
||||
#include "graphics/TimeFormatters.h"
|
||||
#include "graphics/images.h"
|
||||
#include "main.h"
|
||||
#include "modules/CannedMessageModule.h"
|
||||
#include "target_specific.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <RTC.h>
|
||||
@@ -1422,6 +1423,11 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing;
|
||||
const int xStart = (SCREEN_WIDTH - totalWidth) / 2;
|
||||
|
||||
// Suppress nav overlay entirely
|
||||
if (cannedMessageModule && cannedMessageModule->isFreeTextUIActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS;
|
||||
int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT;
|
||||
|
||||
|
||||
+31
-1542
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
#if HAS_SCREEN
|
||||
#include "ProtobufModule.h"
|
||||
#include "input/InputBroker.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// ============================
|
||||
// Enums & Defines
|
||||
@@ -72,6 +74,11 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
bool shouldDraw();
|
||||
bool hasMessages();
|
||||
void resetSearch();
|
||||
// Treat both freetext compose and emote picker as "text compose UI" for global overlays.
|
||||
bool isFreeTextUIActive() const
|
||||
{
|
||||
return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER;
|
||||
}
|
||||
void updateDestinationSelectionList();
|
||||
void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
bool isCharInputAllowed() const;
|
||||
@@ -148,6 +155,20 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
unsigned long lastUpdateMillis = 0;
|
||||
String searchQuery;
|
||||
String freetext;
|
||||
static constexpr uint8_t maxFreeTextCompletions = 4;
|
||||
String freeTextCompletion;
|
||||
String freeTextCompletions[maxFreeTextCompletions];
|
||||
uint8_t freeTextCompletionCount = 0;
|
||||
uint8_t freeTextCompletionIndex = 0;
|
||||
bool freeTextCompletionSuppressed = false;
|
||||
// Cached wrapped token lines for freetext rendering (rebuilt only when content/width changes).
|
||||
String freeTextLayoutCacheText;
|
||||
int freeTextLayoutCacheWidth = -1;
|
||||
std::vector<std::vector<std::pair<bool, String>>> freeTextLayoutCacheLines;
|
||||
// When true, the next emote picker selection replaces this typed prefix span.
|
||||
bool freeTextEmoteReplacePending = false;
|
||||
unsigned int freeTextEmoteReplaceStart = 0;
|
||||
unsigned int freeTextEmoteReplaceLength = 0;
|
||||
|
||||
// === Message Storage ===
|
||||
char messageBuffer[CANNED_MESSAGE_MODULE_MESSAGES_SIZE + 1];
|
||||
@@ -190,6 +211,16 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
int handleDestinationSelectionInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
|
||||
bool handleMessageSelectorInput(const InputEvent *event, bool isUp, bool isDown, bool isSelect);
|
||||
bool handleFreeTextInput(const InputEvent *event);
|
||||
bool tryStartFreeTextFromInactive(const InputEvent *event);
|
||||
void clearComposeState();
|
||||
int32_t runFreeTextState(UIFrameEvent &e);
|
||||
String getFreeTextPrefix() const;
|
||||
void updateFreeTextCompletion();
|
||||
bool cycleFreeTextCompletion(int8_t step);
|
||||
bool acceptFreeTextCompletion(bool appendSpace);
|
||||
void drawFreeTextCompletionRow(OLEDDisplay *display, int16_t x, int16_t rowY, const String &completionPrefix);
|
||||
void drawFreeTextScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, char *buffer);
|
||||
void drawCannedMessageListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, char *buffer);
|
||||
|
||||
#if defined(USE_VIRTUAL_KEYBOARD)
|
||||
Letter keyboard[2][4][10] = {{{{"Q", 20, 0, 0, 0, 0},
|
||||
|
||||
@@ -0,0 +1,999 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
#include "CannedMessageModule.h"
|
||||
#include "Channels.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "buzz.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include "graphics/draw/NotificationRenderer.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "modules/FreeTextModule.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
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;
|
||||
|
||||
namespace
|
||||
{
|
||||
// ASCII lowercase helper keeps filtering deterministic and avoids locale overhead.
|
||||
char toLowerAscii(char c)
|
||||
{
|
||||
if (c >= 'A' && c <= 'Z') {
|
||||
return static_cast<char>(c + ('a' - 'A'));
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Case-insensitive substring search without temporary String allocations.
|
||||
bool containsCaseInsensitiveAscii(const char *text, const char *needle)
|
||||
{
|
||||
if (!text || !needle || !needle[0]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const size_t textLen = strlen(text);
|
||||
const size_t needleLen = strlen(needle);
|
||||
if (needleLen > textLen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i + needleLen <= textLen; ++i) {
|
||||
bool matched = true;
|
||||
for (size_t j = 0; j < needleLen; ++j) {
|
||||
if (toLowerAscii(text[i + j]) != toLowerAscii(needle[j])) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Truncate text to fit width and append ellipsis when clipping is required.
|
||||
void truncateWithEllipsisToWidth(OLEDDisplay *display, char *text, size_t textSize, int maxWidth)
|
||||
{
|
||||
if (!display || !text || textSize == 0) {
|
||||
return;
|
||||
}
|
||||
if (maxWidth <= 0) {
|
||||
text[0] = '\0';
|
||||
return;
|
||||
}
|
||||
if (display->getStringWidth(text) <= maxWidth) {
|
||||
return;
|
||||
}
|
||||
if (textSize < 4) {
|
||||
text[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t originalLen = strlen(text);
|
||||
size_t low = 0;
|
||||
size_t high = std::min(originalLen, textSize - 4);
|
||||
char candidate[64];
|
||||
candidate[0] = '\0';
|
||||
|
||||
// Binary search shortest-fitting clipped token with ellipsis.
|
||||
while (low <= high) {
|
||||
const size_t mid = low + ((high - low) / 2);
|
||||
if (mid >= sizeof(candidate)) {
|
||||
break;
|
||||
}
|
||||
memcpy(candidate, text, mid);
|
||||
candidate[mid] = '\0';
|
||||
strcat(candidate, "...");
|
||||
if (display->getStringWidth(candidate) <= maxWidth) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
if (mid == 0) {
|
||||
break;
|
||||
}
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
const size_t keep = (low == 0) ? 0 : (low - 1);
|
||||
const size_t clipped = std::min(keep, textSize - 4);
|
||||
text[clipped] = '\0';
|
||||
strcat(text, "...");
|
||||
}
|
||||
|
||||
// Find the emote descriptor for a token label.
|
||||
const graphics::Emote *findEmoteByLabel(const String &label)
|
||||
{
|
||||
for (int i = 0; i < graphics::numEmotes; ++i) {
|
||||
if (label == graphics::emotes[i].label) {
|
||||
return &graphics::emotes[i];
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// 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();
|
||||
const bool hasSearchFilter = searchQuery.length() > 0;
|
||||
const char *searchText = searchQuery.c_str();
|
||||
|
||||
// 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 char *nodeName = node->user.long_name;
|
||||
if (!hasSearchFilter) {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
} else {
|
||||
// Compare in place to avoid building lower-cased temporary Strings per node.
|
||||
if (containsCaseInsensitiveAscii(nodeName, searchText)) {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Populate unique active channels without temporary String lists.
|
||||
for (uint8_t i = 0; i < channels.getNumChannels(); ++i) {
|
||||
const char *name = channels.getName(i);
|
||||
if (!name || !name[0]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bool alreadyIncluded = false;
|
||||
for (uint8_t existingIndex : this->activeChannelIndices) {
|
||||
const char *existingName = channels.getName(existingIndex);
|
||||
if (existingName && strcmp(existingName, name) == 0) {
|
||||
alreadyIncluded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyIncluded) {
|
||||
this->activeChannelIndices.push_back(i);
|
||||
}
|
||||
}
|
||||
|
||||
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<int>(activeChannelIndices.size())) {
|
||||
dest = NODENUM_BROADCAST;
|
||||
channel = activeChannelIndices[destIndex];
|
||||
lastDest = dest;
|
||||
lastChannel = channel;
|
||||
lastDestSet = true;
|
||||
} else {
|
||||
int nodeIndex = destIndex - static_cast<int>(activeChannelIndices.size());
|
||||
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(filteredNodes.size())) {
|
||||
const meshtastic_NodeInfoLite *selectedNode = filteredNodes[nodeIndex].node;
|
||||
if (selectedNode) {
|
||||
dest = selectedNode->num;
|
||||
channel = selectedNode->channel;
|
||||
// Already saves here, but for clarity, also:
|
||||
lastDest = dest;
|
||||
lastChannel = channel;
|
||||
lastDestSet = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
returnToCannedList = false;
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// CANCEL
|
||||
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) {
|
||||
runState = returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
returnToCannedList = false;
|
||||
searchQuery = "";
|
||||
|
||||
// UIFrameEvent e;
|
||||
// e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
// notifyObservers(&e);
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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<int>(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;
|
||||
truncateWithEllipsisToWidth(display, entryText, sizeof(entryText), availWidth);
|
||||
|
||||
// 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<int>(this->filteredNodes.size())) {
|
||||
const meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
|
||||
if (node && hasKeyForNode(node)) {
|
||||
int iconX = display->getWidth() - key_symbol_width - 15;
|
||||
int iconY = yOffset + (FONT_HEIGHT_SMALL - key_symbol_height) / 2;
|
||||
|
||||
if (itemIndex == destIndex) {
|
||||
display->setColor(INVERSE);
|
||||
} else {
|
||||
display->setColor(WHITE);
|
||||
}
|
||||
display->drawXbm(iconX, iconY, key_symbol_width, key_symbol_height, key_symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
// Scrollbar
|
||||
if (totalEntries > visibleRows) {
|
||||
int scrollbarHeight = visibleRows * (FONT_HEIGHT_SMALL - 4);
|
||||
int totalScrollable = totalEntries;
|
||||
int scrollTrackX = display->getWidth() - 6;
|
||||
display->drawRect(scrollTrackX, rowYOffset, 4, scrollbarHeight);
|
||||
int scrollHeight = (scrollbarHeight * visibleRows) / totalScrollable;
|
||||
int scrollPos = rowYOffset + (scrollbarHeight * scrollIndex) / totalScrollable;
|
||||
display->fillRect(scrollTrackX, scrollPos, 4, scrollHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
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.
|
||||
topMsg =
|
||||
(messagesCount > _visibleRows && currentMessageIndex >= _visibleRows - 1) ? currentMessageIndex - _visibleRows + 2 : 0;
|
||||
int countRows = std::min(messagesCount, _visibleRows);
|
||||
|
||||
// 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);
|
||||
bool _highlight = (msgIdx == currentMessageIndex);
|
||||
|
||||
// Multi-emote tokenization.
|
||||
std::vector<std::pair<bool, String>> tokens = freeTextModule::tokenizeMessageWithEmotes(msg);
|
||||
int maxEmoteHeight = 0;
|
||||
for (const auto &token : tokens) {
|
||||
if (!token.first) {
|
||||
continue;
|
||||
}
|
||||
const graphics::Emote *emote = findEmoteByLabel(token.second);
|
||||
if (emote && emote->height > maxEmoteHeight) {
|
||||
maxEmoteHeight = emote->height;
|
||||
}
|
||||
}
|
||||
const int rowHeight = std::max(baseRowSpacing, maxEmoteHeight + 2);
|
||||
|
||||
// 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<StoredMessage &>(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;
|
||||
truncateWithEllipsisToWidth(display, nodeName, sizeof(nodeName), availWidth);
|
||||
|
||||
// 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
#if HAS_SCREEN
|
||||
|
||||
#include "configuration.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
class OLEDDisplay;
|
||||
|
||||
namespace freeTextModule
|
||||
{
|
||||
std::vector<std::pair<bool, String>> tokenizeMessageWithEmotes(const char *msg);
|
||||
void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label);
|
||||
} // namespace freeTextModule
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user