mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-14 15:31:31 +10:00
Optimization
This commit is contained in:
@@ -210,6 +210,17 @@ void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset transient compose state used by canned/freetext flows.
|
||||
void CannedMessageModule::clearComposeState()
|
||||
{
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->freeTextLayoutCacheText = "";
|
||||
this->freeTextLayoutCacheWidth = -1;
|
||||
this->freeTextLayoutCacheLines.clear();
|
||||
}
|
||||
|
||||
// Returns true if character input is currently allowed (used for search/freetext states)
|
||||
bool CannedMessageModule::isCharInputAllowed() const
|
||||
{
|
||||
@@ -381,9 +392,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
this->notifyObservers(&e);
|
||||
return 2000;
|
||||
}
|
||||
@@ -395,25 +404,19 @@ int32_t CannedMessageModule::runOnce()
|
||||
(this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) {
|
||||
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
this->notifyObservers(&e);
|
||||
}
|
||||
// Handle SENDING_ACTIVE state transition after virtual keyboard message
|
||||
else if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == 0) {
|
||||
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
return INT32_MAX;
|
||||
} else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) &&
|
||||
!Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) {
|
||||
// Reset module on inactivity
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
|
||||
|
||||
// Clean up virtual keyboard if it exists during timeout
|
||||
@@ -434,9 +437,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
sendText(this->dest, this->channel, this->freetext.c_str(), true);
|
||||
|
||||
// Clean up state but *don’t* deactivate yet
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
|
||||
// Tell Screen to jump straight to the TextMessage frame
|
||||
e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
|
||||
@@ -461,9 +462,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
sendText(this->dest, this->channel, this->messages[this->currentMessageIndex], true);
|
||||
|
||||
// Clean up state
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
|
||||
// Tell Screen to jump straight to the TextMessage frame
|
||||
e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
|
||||
@@ -479,9 +478,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
}
|
||||
}
|
||||
// fallback clean-up if nothing above returned
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->clearComposeState();
|
||||
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#if HAS_SCREEN
|
||||
#include "ProtobufModule.h"
|
||||
#include "input/InputBroker.h"
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
// ============================
|
||||
// Enums & Defines
|
||||
@@ -155,6 +157,10 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
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;
|
||||
@@ -202,6 +208,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
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();
|
||||
|
||||
@@ -29,6 +29,106 @@ 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()
|
||||
{
|
||||
@@ -69,8 +169,8 @@ void CannedMessageModule::updateDestinationSelectionList()
|
||||
this->activeChannelIndices.clear();
|
||||
|
||||
NodeNum myNodeNum = nodeDB->getNodeNum();
|
||||
String lowerSearchQuery = searchQuery;
|
||||
lowerSearchQuery.toLowerCase();
|
||||
const bool hasSearchFilter = searchQuery.length() > 0;
|
||||
const char *searchText = searchQuery.c_str();
|
||||
|
||||
// Preallocate space to reduce reallocation
|
||||
this->filteredNodes.reserve(numMeshNodes);
|
||||
@@ -80,33 +180,35 @@ void CannedMessageModule::updateDestinationSelectionList()
|
||||
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) {
|
||||
const char *nodeName = node->user.long_name;
|
||||
if (!hasSearchFilter) {
|
||||
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) {
|
||||
// Compare in place to avoid building lower-cased temporary Strings per node.
|
||||
if (containsCaseInsensitiveAscii(nodeName, searchText)) {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
p->pki_encrypted = true;
|
||||
p->channel = 0;
|
||||
|
||||
// Populate active channels
|
||||
std::vector<String> seenChannels;
|
||||
seenChannels.reserve(channels.getNumChannels());
|
||||
// Populate unique active channels without temporary String lists.
|
||||
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()) {
|
||||
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);
|
||||
seenChannels.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,14 +671,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
((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, "...");
|
||||
}
|
||||
truncateWithEllipsisToWidth(display, entryText, sizeof(entryText), availWidth);
|
||||
|
||||
// Prepend "* " if this is a favorite
|
||||
if (node && node->is_favorite) {
|
||||
@@ -659,7 +754,6 @@ void CannedMessageModule::drawCannedMessageListScreen(OLEDDisplay *display, OLED
|
||||
const int baseRowSpacing = FONT_HEIGHT_SMALL - 4;
|
||||
|
||||
int topMsg;
|
||||
std::vector<int> rowHeights;
|
||||
int _visibleRows;
|
||||
|
||||
// Draw header (To: ...).
|
||||
@@ -669,40 +763,32 @@ void CannedMessageModule::drawCannedMessageListScreen(OLEDDisplay *display, OLED
|
||||
const int listYOffset = y + FONT_HEIGHT_SMALL - 3;
|
||||
_visibleRows = (display->getHeight() - listYOffset) / baseRowSpacing;
|
||||
|
||||
// Figure out which messages are visible and their needed heights.
|
||||
// Figure out which messages are visible.
|
||||
topMsg =
|
||||
(messagesCount > _visibleRows && currentMessageIndex >= _visibleRows - 1) ? currentMessageIndex - _visibleRows + 2 : 0;
|
||||
int countRows = std::min(messagesCount, _visibleRows);
|
||||
|
||||
// Build per-row max height based on all emotes in line.
|
||||
for (int i = 0; i < countRows; i++) {
|
||||
const char *msg = getMessageByIndex(topMsg + i);
|
||||
int maxEmoteHeight = 0;
|
||||
for (int j = 0; j < graphics::numEmotes; j++) {
|
||||
const char *label = graphics::emotes[j].label;
|
||||
if (!label || !*label)
|
||||
continue;
|
||||
const char *search = msg;
|
||||
while ((search = strstr(search, label))) {
|
||||
if (graphics::emotes[j].height > maxEmoteHeight)
|
||||
maxEmoteHeight = graphics::emotes[j].height;
|
||||
search += strlen(label); // Advance past this emote.
|
||||
}
|
||||
}
|
||||
rowHeights.push_back(std::max(baseRowSpacing, maxEmoteHeight + 2));
|
||||
}
|
||||
|
||||
// Draw all message rows with multi-emote support.
|
||||
int yCursor = listYOffset;
|
||||
for (int vis = 0; vis < countRows; vis++) {
|
||||
int msgIdx = topMsg + vis;
|
||||
int lineY = yCursor;
|
||||
const char *msg = getMessageByIndex(msgIdx);
|
||||
int rowHeight = rowHeights[vis];
|
||||
bool _highlight = (msgIdx == currentMessageIndex);
|
||||
|
||||
// Multi-emote tokenization.
|
||||
std::vector<std::pair<bool, String>> tokens = 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;
|
||||
@@ -871,14 +957,7 @@ ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &
|
||||
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, "...");
|
||||
}
|
||||
truncateWithEllipsisToWidth(display, nodeName, sizeof(nodeName), availWidth);
|
||||
|
||||
// Calculate signal quality and bars based on preset, SNR, and RSSI
|
||||
float snrLimit = getSnrLimit(config.lora.modem_preset);
|
||||
@@ -917,4 +996,4 @@ ProcessMessage CannedMessageModule::handleReceived(const meshtastic_MeshPacket &
|
||||
return ProcessMessage::CONTINUE;
|
||||
}
|
||||
|
||||
#endif
|
||||
#endif
|
||||
+108
-78
@@ -20,6 +20,7 @@ static constexpr int32_t kInactivateAfterMs = 20000;
|
||||
std::vector<std::pair<bool, String>> freeTextModule::tokenizeMessageWithEmotes(const char *msg)
|
||||
{
|
||||
std::vector<std::pair<bool, String>> tokens;
|
||||
tokens.reserve(8);
|
||||
int msgLen = strlen(msg);
|
||||
int pos = 0;
|
||||
while (pos < msgLen) {
|
||||
@@ -597,8 +598,8 @@ bool CannedMessageModule::acceptFreeTextCompletion(bool appendSpace)
|
||||
|
||||
String prefixLower = getFreeTextPrefix();
|
||||
const bool isEmoteCompletion = (this->freeTextCompletion == kFreeTextEmoteCompletion);
|
||||
if (prefixLower.length() == 0 || this->cursor < prefixLower.length() ||
|
||||
(!isEmoteCompletion && prefixLower.length() < 2) || this->freeTextCompletion.length() <= prefixLower.length()) {
|
||||
if (prefixLower.length() == 0 || this->cursor < prefixLower.length() || (!isEmoteCompletion && prefixLower.length() < 2) ||
|
||||
this->freeTextCompletion.length() <= prefixLower.length()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -614,7 +615,7 @@ bool CannedMessageModule::acceptFreeTextCompletion(bool appendSpace)
|
||||
this->runState = CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER;
|
||||
this->requestFocus();
|
||||
if (screen) {
|
||||
screen->forceDisplay();
|
||||
screen->forceDisplay(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -713,7 +714,8 @@ void CannedMessageModule::drawFreeTextCompletionRow(OLEDDisplay *display, int16_
|
||||
return -1;
|
||||
};
|
||||
|
||||
std::vector<ChoiceLayout> choices;
|
||||
ChoiceLayout choices[maxFreeTextCompletions];
|
||||
uint8_t choiceCount = 0;
|
||||
int runningX = 0;
|
||||
bool hasChoices = false;
|
||||
int selectedStart = 0;
|
||||
@@ -736,7 +738,9 @@ void CannedMessageModule::drawFreeTextCompletionRow(OLEDDisplay *display, int16_
|
||||
const int emoteIndex = findEmoteIndex(candidate);
|
||||
int tokenWidth = (emoteIndex >= 0) ? graphics::emotes[emoteIndex].width : display->getStringWidth(candidate);
|
||||
int chipWidth = tokenWidth + (chipPaddingX * 2);
|
||||
choices.push_back({i, candidate, runningX, chipWidth, emoteIndex});
|
||||
if (choiceCount < maxFreeTextCompletions) {
|
||||
choices[choiceCount++] = {i, candidate, runningX, chipWidth, emoteIndex};
|
||||
}
|
||||
if (i == this->freeTextCompletionIndex) {
|
||||
selectedStart = runningX;
|
||||
selectedEnd = runningX + chipWidth;
|
||||
@@ -762,7 +766,8 @@ void CannedMessageModule::drawFreeTextCompletionRow(OLEDDisplay *display, int16_
|
||||
const int centeredOffsetX = (runningX < viewportWidth) ? ((viewportWidth - runningX) / 2) : 0;
|
||||
const int drawBaseX = x + centeredOffsetX;
|
||||
|
||||
for (const auto &choice : choices) {
|
||||
for (uint8_t i = 0; i < choiceCount; ++i) {
|
||||
const ChoiceLayout &choice = choices[i];
|
||||
int boxX = drawBaseX + (choice.startX - choiceScrollX);
|
||||
if ((boxX + choice.width) < x || boxX > (x + viewportWidth)) {
|
||||
continue;
|
||||
@@ -872,8 +877,8 @@ int32_t CannedMessageModule::runFreeTextState(UIFrameEvent &e)
|
||||
const size_t emoteLen = emoteLabelLengthEndingAtCursor(this->freetext, this->cursor);
|
||||
const unsigned int deleteLen = (emoteLen > 0) ? static_cast<unsigned int>(emoteLen) : 1;
|
||||
const unsigned int deleteStart = this->cursor - deleteLen;
|
||||
this->freetext = this->freetext.substring(0, deleteStart) +
|
||||
this->freetext.substring(this->cursor, this->freetext.length());
|
||||
this->freetext =
|
||||
this->freetext.substring(0, deleteStart) + this->freetext.substring(this->cursor, this->freetext.length());
|
||||
this->cursor = deleteStart;
|
||||
}
|
||||
break;
|
||||
@@ -990,7 +995,8 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
|
||||
this->freeTextEmoteReplaceLength = 0;
|
||||
runState = CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER;
|
||||
requestFocus();
|
||||
screen->forceDisplay();
|
||||
// Force immediate UI refresh so picker appears/snaps quickly on low idle FPS.
|
||||
screen->forceDisplay(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1098,12 +1104,14 @@ int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
|
||||
|
||||
if (isUp && emotePickerIndex > 0) {
|
||||
emotePickerIndex--;
|
||||
screen->forceDisplay();
|
||||
// Non-EInk builds need forceUiUpdate=true for responsive per-step redraw.
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
if (isDown && emotePickerIndex < numEmotes - 1) {
|
||||
emotePickerIndex++;
|
||||
screen->forceDisplay();
|
||||
// Non-EInk builds need forceUiUpdate=true for responsive per-step redraw.
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1128,7 +1136,7 @@ int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
|
||||
this->freeTextEmoteReplaceStart = 0;
|
||||
this->freeTextEmoteReplaceLength = 0;
|
||||
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
screen->forceDisplay();
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1140,7 +1148,7 @@ int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
|
||||
this->freeTextCompletionSuppressed = false;
|
||||
this->updateFreeTextCompletion();
|
||||
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
|
||||
screen->forceDisplay();
|
||||
screen->forceDisplay(true);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1256,70 +1264,78 @@ void CannedMessageModule::drawFreeTextScreen(OLEDDisplay *display, OLEDDisplayUi
|
||||
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<std::pair<bool, String>> tokens = freeTextModule::tokenizeMessageWithEmotes(msg);
|
||||
// Rebuild wrapped token lines only when text or viewport width changes.
|
||||
const int maxWidth = display->getWidth();
|
||||
if (this->freeTextLayoutCacheWidth != maxWidth || this->freeTextLayoutCacheText != msgWithCursor) {
|
||||
this->freeTextLayoutCacheWidth = maxWidth;
|
||||
this->freeTextLayoutCacheText = msgWithCursor;
|
||||
this->freeTextLayoutCacheLines.clear();
|
||||
|
||||
// Advanced word-wrapping (emotes + text, split by word, wrap inside word if needed).
|
||||
std::vector<std::vector<std::pair<bool, String>>> lines;
|
||||
std::vector<std::pair<bool, String>> currentLine;
|
||||
int lineWidth = 0;
|
||||
int maxWidth = display->getWidth();
|
||||
for (auto &token : tokens) {
|
||||
if (token.first) {
|
||||
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);
|
||||
const char *msg = msgWithCursor.c_str();
|
||||
std::vector<std::pair<bool, String>> tokens = freeTextModule::tokenizeMessageWithEmotes(msg);
|
||||
std::vector<std::pair<bool, String>> currentLine;
|
||||
currentLine.reserve(8);
|
||||
int lineWidth = 0;
|
||||
|
||||
// Commit the current line and reset wrapping state.
|
||||
auto flushLine = [this, ¤tLine, &lineWidth]() {
|
||||
if (!currentLine.empty()) {
|
||||
this->freeTextLayoutCacheLines.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<int>(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++;
|
||||
for (const 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;
|
||||
}
|
||||
} else {
|
||||
currentLine.push_back({false, word});
|
||||
lineWidth += wordWidth;
|
||||
}
|
||||
pos = endPos;
|
||||
if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) {
|
||||
flushLine();
|
||||
}
|
||||
currentLine.push_back(token);
|
||||
lineWidth += tokenWidth;
|
||||
} else {
|
||||
// Text: split by words and wrap inside a long word when needed.
|
||||
const String &text = token.second;
|
||||
int pos = 0;
|
||||
while (pos < static_cast<int>(text.length())) {
|
||||
int spacePos = text.indexOf(' ', pos);
|
||||
int endPos = (spacePos == -1) ? text.length() : (spacePos + 1); // Include trailing space.
|
||||
String word = text.substring(pos, endPos);
|
||||
int wordWidth = display->getStringWidth(word);
|
||||
|
||||
if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
|
||||
flushLine();
|
||||
}
|
||||
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) {
|
||||
flushLine();
|
||||
}
|
||||
currentLine.push_back({false, oneChar});
|
||||
lineWidth += charWidth;
|
||||
charPos++;
|
||||
}
|
||||
} else {
|
||||
currentLine.push_back({false, word});
|
||||
lineWidth += wordWidth;
|
||||
}
|
||||
pos = endPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
flushLine();
|
||||
}
|
||||
if (!currentLine.empty())
|
||||
lines.push_back(currentLine);
|
||||
const auto &lines = this->freeTextLayoutCacheLines;
|
||||
|
||||
const int rowHeight = std::max(8, FONT_HEIGHT_SMALL - 3);
|
||||
const int viewportTop = inputY;
|
||||
@@ -1332,7 +1348,8 @@ void CannedMessageModule::drawFreeTextScreen(OLEDDisplay *display, OLEDDisplayUi
|
||||
|
||||
// Reserve enough space for the completion row so chip/text rendering is not clipped.
|
||||
const int completionRowHeight = FONT_HEIGHT_SMALL + 1;
|
||||
const int textViewportBottom = showCompletionRow ? std::max(viewportTop, viewportBottom - completionRowHeight) : viewportBottom;
|
||||
const int textViewportBottom =
|
||||
showCompletionRow ? std::max(viewportTop, viewportBottom - completionRowHeight) : viewportBottom;
|
||||
const int viewportHeight = std::max(1, textViewportBottom - viewportTop);
|
||||
const int viewportRows = std::max(1, viewportHeight / rowHeight);
|
||||
|
||||
@@ -1397,22 +1414,35 @@ void CannedMessageModule::drawFreeTextScreen(OLEDDisplay *display, OLEDDisplayUi
|
||||
// Draw scrollable emote picker list.
|
||||
void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
|
||||
EInkDynamicDisplay *einkDisplay = static_cast<EInkDynamicDisplay *>(display);
|
||||
einkDisplay->enableUnlimitedFastMode();
|
||||
#endif
|
||||
|
||||
const int headerFontHeight = FONT_HEIGHT_SMALL;
|
||||
const int headerMargin = 2;
|
||||
const int labelGap = 6;
|
||||
const int bitmapGapX = 4;
|
||||
const int displayWidth = display->getWidth();
|
||||
const int displayHeight = display->getHeight();
|
||||
|
||||
int maxEmoteHeight = 0;
|
||||
for (int i = 0; i < graphics::numEmotes; ++i) {
|
||||
if (graphics::emotes[i].height > maxEmoteHeight) {
|
||||
maxEmoteHeight = graphics::emotes[i].height;
|
||||
// Cache max row bitmap height; emote table is static in runtime.
|
||||
static int cachedMaxEmoteHeight = -1;
|
||||
static int cachedNumEmotes = -1;
|
||||
if (cachedMaxEmoteHeight < 0 || cachedNumEmotes != graphics::numEmotes) {
|
||||
cachedMaxEmoteHeight = 0;
|
||||
for (int i = 0; i < graphics::numEmotes; ++i) {
|
||||
if (graphics::emotes[i].height > cachedMaxEmoteHeight) {
|
||||
cachedMaxEmoteHeight = graphics::emotes[i].height;
|
||||
}
|
||||
}
|
||||
cachedNumEmotes = graphics::numEmotes;
|
||||
}
|
||||
|
||||
const int rowHeight = maxEmoteHeight + 2;
|
||||
const int rowHeight = cachedMaxEmoteHeight + 2;
|
||||
int headerY = y;
|
||||
int listTop = headerY + headerFontHeight + headerMargin;
|
||||
int _visibleRows = (display->getHeight() - listTop - 2) / rowHeight;
|
||||
int _visibleRows = (displayHeight - listTop - 2) / rowHeight;
|
||||
int numEmotes = graphics::numEmotes;
|
||||
|
||||
this->visibleRows = _visibleRows;
|
||||
@@ -1434,8 +1464,9 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
|
||||
|
||||
display->setFont(FONT_SMALL);
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(display->getWidth() / 2, headerY, "Select Emote");
|
||||
display->drawString(displayWidth / 2, headerY, "Select Emote");
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_MEDIUM);
|
||||
|
||||
for (int vis = 0; vis < _visibleRows; ++vis) {
|
||||
int emoteIdx = topIndex + vis;
|
||||
@@ -1446,14 +1477,13 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
|
||||
int rowY = listTop + vis * rowHeight;
|
||||
|
||||
if (emoteIdx == emotePickerIndex) {
|
||||
display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2);
|
||||
display->fillRect(x, rowY, displayWidth - 8, rowHeight);
|
||||
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);
|
||||
|
||||
@@ -1464,7 +1494,7 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
|
||||
|
||||
if (numEmotes > _visibleRows) {
|
||||
int scrollbarHeight = _visibleRows * rowHeight;
|
||||
int scrollTrackX = display->getWidth() - 6;
|
||||
int scrollTrackX = displayWidth - 6;
|
||||
display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight);
|
||||
int scrollBarLen = std::max(6, (scrollbarHeight * _visibleRows) / numEmotes);
|
||||
int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes;
|
||||
|
||||
Reference in New Issue
Block a user