Fn+e emote picker for freetext screen

This commit is contained in:
HarukiToreda 2025-06-06 00:12:04 -04:00
parent 0c1d49e254
commit 97eb03cb35
4 changed files with 307 additions and 30 deletions

View File

@ -20,6 +20,7 @@
#define INPUT_BROKER_MSG_FN_SYMBOL_OFF 0xf2 #define INPUT_BROKER_MSG_FN_SYMBOL_OFF 0xf2
#define INPUT_BROKER_MSG_BLUETOOTH_TOGGLE 0xAA #define INPUT_BROKER_MSG_BLUETOOTH_TOGGLE 0xAA
#define INPUT_BROKER_MSG_TAB 0x09 #define INPUT_BROKER_MSG_TAB 0x09
#define INPUT_BROKER_MSG_EMOTE_LIST 0x8F
typedef struct _InputEvent { typedef struct _InputEvent {
const char *source; const char *source;

View File

@ -435,6 +435,7 @@ int32_t KbI2cBase::runOnce()
case 0xaf: // fn+space INPUT_BROKER_MSG_SEND_PING case 0xaf: // fn+space INPUT_BROKER_MSG_SEND_PING
case 0x8b: // fn+del INPUT_BROKEN_MSG_DISMISS_FRAME case 0x8b: // fn+del INPUT_BROKEN_MSG_DISMISS_FRAME
case 0xAA: // fn+b INPUT_BROKER_MSG_BLUETOOTH_TOGGLE case 0xAA: // fn+b INPUT_BROKER_MSG_BLUETOOTH_TOGGLE
case 0x8F: // fn+e INPUT_BROKER_MSG_EMOTE_LIST
// just pass those unmodified // just pass those unmodified
e.inputEvent = ANYKEY; e.inputEvent = ANYKEY;
e.kbchar = c; e.kbchar = c;

View File

@ -298,6 +298,10 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE: case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE:
return 1; return 1;
// If sending, block all input except global/system (handled above)
case CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER:
return handleEmotePickerInput(event);
case CANNED_MESSAGE_RUN_STATE_INACTIVE: case CANNED_MESSAGE_RUN_STATE_INACTIVE:
if (isSelect) { if (isSelect) {
// When inactive, call the onebutton shortpress instead. Activate module only on up/down // When inactive, call the onebutton shortpress instead. Activate module only on up/down
@ -647,6 +651,12 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
// ---- All hardware keys fall through to here (CardKB, physical, etc.) ---- // ---- 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) // Confirm select (Enter)
bool isSelect = isSelectEvent(event); bool isSelect = isSelectEvent(event);
if (isSelect) { if (isSelect) {
@ -715,6 +725,47 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
return false; return false;
} }
int CannedMessageModule::handleEmotePickerInput(const InputEvent *event)
{
int numEmotes = graphics::numEmotes;
bool isUp = isUpEvent(event);
bool isDown = isDownEvent(event);
bool isSelect = isSelectEvent(event);
// 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 == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL)) {
runState = CANNED_MESSAGE_RUN_STATE_FREETEXT;
screen->forceDisplay();
return 1;
}
return 0;
}
bool CannedMessageModule::handleSystemCommandInput(const InputEvent *event) bool CannedMessageModule::handleSystemCommandInput(const InputEvent *event)
{ {
// Only respond to "ANYKEY" events for system keys // Only respond to "ANYKEY" events for system keys
@ -997,7 +1048,8 @@ int32_t CannedMessageModule::runOnce()
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
switch (this->payload) { switch (this->payload) {
case 0x08: // backspace case 0x08: // backspace
if (this->freetext.length() > 0 && this->highlight == 0x00) { if (this->freetext.length() > 0) {
if (this->cursor > 0) {
if (this->cursor == this->freetext.length()) { if (this->cursor == this->freetext.length()) {
this->freetext = this->freetext.substring(0, this->freetext.length() - 1); this->freetext = this->freetext.substring(0, this->freetext.length() - 1);
} else { } else {
@ -1006,6 +1058,7 @@ int32_t CannedMessageModule::runOnce()
} }
this->cursor--; this->cursor--;
} }
}
break; break;
case INPUT_BROKER_MSG_TAB: // Tab key: handled by input handler case INPUT_BROKER_MSG_TAB: // Tab key: handled by input handler
return 0; return 0;
@ -1013,20 +1066,21 @@ int32_t CannedMessageModule::runOnce()
case INPUT_BROKER_MSG_RIGHT: case INPUT_BROKER_MSG_RIGHT:
break; break;
default: default:
if (this->highlight != 0x00) // Only insert ASCII printable characters (32126)
break; if (this->payload >= 32 && this->payload <= 126) {
if (this->cursor == this->freetext.length()) { if (this->cursor == this->freetext.length()) {
this->freetext += this->payload; this->freetext += (char)this->payload;
} else { } else {
this->freetext = this->freetext =
this->freetext.substring(0, this->cursor) + this->payload + this->freetext.substring(this->cursor); this->freetext.substring(0, this->cursor) + (char)this->payload + this->freetext.substring(this->cursor);
} }
this->cursor += 1; this->cursor++;
uint16_t maxChars = meshtastic_Constants_DATA_PAYLOAD_LEN - (moduleConfig.canned_message.send_bell ? 1 : 0); uint16_t maxChars = meshtastic_Constants_DATA_PAYLOAD_LEN - (moduleConfig.canned_message.send_bell ? 1 : 0);
if (this->freetext.length() > maxChars) { if (this->freetext.length() > maxChars) {
this->cursor = maxChars; this->cursor = maxChars;
this->freetext = this->freetext.substring(0, maxChars); this->freetext = this->freetext.substring(0, maxChars);
} }
}
break; break;
} }
} }
@ -1443,6 +1497,81 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
} }
} }
void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
const int headerFontHeight = FONT_HEIGHT_SMALL; // Make sure this matches your actual small font height
const int headerMargin = 2; // Extra pixels below header
const int labelGap = 6;
const int bitmapGapX = 4;
// Find max emote height (assume all same, or precalculated)
int maxEmoteHeight = 0;
for (int i = 0; i < graphics::numEmotes; ++i)
if (graphics::emotes[i].height > maxEmoteHeight)
maxEmoteHeight = graphics::emotes[i].height;
const int rowHeight = maxEmoteHeight + 2;
// Place header at top, then compute start of emote list
int headerY = y;
int listTop = headerY + headerFontHeight + headerMargin;
int visibleRows = (display->getHeight() - listTop - 2) / rowHeight;
int numEmotes = graphics::numEmotes;
// Clamp highlight index
if (emotePickerIndex < 0) emotePickerIndex = 0;
if (emotePickerIndex >= numEmotes) emotePickerIndex = numEmotes - 1;
// Determine which emote is at the top
int topIndex = emotePickerIndex - visibleRows / 2;
if (topIndex < 0) topIndex = 0;
if (topIndex > numEmotes - visibleRows) topIndex = std::max(0, numEmotes - visibleRows);
// Draw header/title
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(display->getWidth() / 2, headerY, "Select Emote");
// Draw emote rows
display->setTextAlignment(TEXT_ALIGN_LEFT);
for (int vis = 0; vis < visibleRows; ++vis) {
int emoteIdx = topIndex + vis;
if (emoteIdx >= numEmotes) break;
const graphics::Emote& emote = graphics::emotes[emoteIdx];
int rowY = listTop + vis * rowHeight;
// Draw highlight box 2px taller than emote (1px margin above and below)
if (emoteIdx == emotePickerIndex) {
display->fillRect(x, rowY, display->getWidth() - 8, emote.height + 2);
display->setColor(BLACK);
}
// Emote bitmap (left), 1px margin from highlight bar top
int emoteY = rowY + 1;
display->drawXbm(x + bitmapGapX, emoteY, emote.width, emote.height, emote.bitmap);
// Emote label (right of bitmap)
display->setFont(FONT_MEDIUM);
int labelY = rowY + ((rowHeight - FONT_HEIGHT_MEDIUM) / 2);
display->drawString(x + bitmapGapX + emote.width + labelGap, labelY, emote.label);
if (emoteIdx == emotePickerIndex)
display->setColor(WHITE);
}
// Draw scrollbar if needed
if (numEmotes > visibleRows) {
int scrollbarHeight = visibleRows * rowHeight;
int scrollTrackX = display->getWidth() - 6;
display->drawRect(scrollTrackX, listTop, 4, scrollbarHeight);
int scrollBarLen = std::max(6, (scrollbarHeight * visibleRows) / numEmotes);
int scrollBarPos = listTop + (scrollbarHeight * topIndex) / numEmotes;
display->fillRect(scrollTrackX, scrollBarPos, 4, scrollBarLen);
}
}
void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) 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
@ -1460,6 +1589,12 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
return; return;
} }
// === Emote Picker Screen ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER) {
drawEmotePickerScreen(display, state, x, y); // <-- Call your emote picker drawer here
return;
}
// === Destination Selection === // === Destination Selection ===
if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) { if (this->runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
drawDestinationSelectionScreen(display, state, x, y); drawDestinationSelectionScreen(display, state, x, y);
@ -1562,10 +1697,145 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer); display->drawString(x + display->getWidth() - display->getStringWidth(buffer), y + 0, buffer);
} }
// --- Draw Free Text input, shifted down --- // --- Draw Free Text input with multi-emote support and proper line wrapping ---
display->setColor(WHITE); display->setColor(WHITE);
display->drawStringMaxWidth(0 + x, 0 + y + FONT_HEIGHT_SMALL, x + display->getWidth(), {
drawWithCursor(this->freetext, this->cursor)); int inputY = 0 + y + FONT_HEIGHT_SMALL;
String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor);
// Tokenize input into (isEmote, token) pairs
std::vector<std::pair<bool, String>> tokens;
const char* msg = msgWithCursor.c_str();
int msgLen = strlen(msg);
int pos = 0;
while (pos < msgLen) {
const graphics::Emote* foundEmote = nullptr;
int foundLen = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
const char* label = graphics::emotes[j].label;
int labelLen = strlen(label);
if (labelLen == 0) continue;
if (strncmp(msg + pos, label, labelLen) == 0) {
if (!foundEmote || labelLen > foundLen) {
foundEmote = &graphics::emotes[j];
foundLen = labelLen;
}
}
}
if (foundEmote) {
tokens.emplace_back(true, String(foundEmote->label));
pos += foundLen;
} else {
// Find next emote
int nextEmote = msgLen;
for (int j = 0; j < graphics::numEmotes; j++) {
const char* label = graphics::emotes[j].label;
if (!label || !*label) continue;
char* found = strstr(msg + pos, label);
if (found && (found - msg) < nextEmote) {
nextEmote = found - msg;
}
}
int textLen = (nextEmote > pos) ? (nextEmote - pos) : (msgLen - pos);
if (textLen > 0) {
tokens.emplace_back(false, String(msg + pos).substring(0, textLen));
pos += textLen;
} else {
break;
}
}
}
// ===== Advanced word-wrapping (emotes + text, split by word, wrap by char if needed) =====
std::vector<std::vector<std::pair<bool, String>>> lines;
std::vector<std::pair<bool, String>> currentLine;
int lineWidth = 0;
int maxWidth = display->getWidth();
for (auto& token : tokens) {
if (token.first) {
// Emote
int tokenWidth = 0;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
tokenWidth = graphics::emotes[j].width + 2;
break;
}
}
if (lineWidth + tokenWidth > maxWidth && !currentLine.empty()) {
lines.push_back(currentLine);
currentLine.clear();
lineWidth = 0;
}
currentLine.push_back(token);
lineWidth += tokenWidth;
} else {
// Text: split by words and wrap inside word if needed
String text = token.second;
int pos = 0;
while (pos < 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) {
int 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);
// Draw lines with emotes
int rowHeight = FONT_HEIGHT_SMALL;
int yLine = inputY;
for (auto& line : lines) {
int nextX = x;
for (auto& token : line) {
if (token.first) {
const graphics::Emote* emote = nullptr;
for (int j = 0; j < graphics::numEmotes; j++) {
if (token.second == graphics::emotes[j].label) {
emote = &graphics::emotes[j];
break;
}
}
if (emote) {
int emoteYOffset = (rowHeight - emote->height) / 2;
display->drawXbm(nextX, yLine + emoteYOffset, emote->width, emote->height, emote->bitmap);
nextX += emote->width + 2;
}
} else {
display->drawString(nextX, yLine, token.second);
nextX += display->getStringWidth(token.second);
}
}
yLine += rowHeight;
}
}
#endif #endif
return; return;
} }
@ -1625,9 +1895,9 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
int msgLen = strlen(msg); int msgLen = strlen(msg);
while (pos < msgLen) { while (pos < msgLen) {
const graphics::Emote* foundEmote = nullptr; const graphics::Emote* foundEmote = nullptr;
int foundAt = -1, foundLen = 0; int foundLen = 0;
// Look for any emote at this pos (prefer longest match) // Look for any emote label at this pos (prefer longest match)
for (int j = 0; j < graphics::numEmotes; j++) { for (int j = 0; j < graphics::numEmotes; j++) {
const char* label = graphics::emotes[j].label; const char* label = graphics::emotes[j].label;
int labelLen = strlen(label); int labelLen = strlen(label);
@ -1635,14 +1905,11 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
if (strncmp(msg + pos, label, labelLen) == 0) { if (strncmp(msg + pos, label, labelLen) == 0) {
if (!foundEmote || labelLen > foundLen) { if (!foundEmote || labelLen > foundLen) {
foundEmote = &graphics::emotes[j]; foundEmote = &graphics::emotes[j];
foundAt = pos;
foundLen = labelLen; foundLen = labelLen;
} }
} }
} }
if (foundEmote) {
if (foundEmote && foundAt == pos) {
// Emote at current pos
tokens.emplace_back(true, String(foundEmote->label)); tokens.emplace_back(true, String(foundEmote->label));
pos += foundLen; pos += foundLen;
} else { } else {

View File

@ -18,7 +18,8 @@ enum cannedMessageModuleRunState {
CANNED_MESSAGE_RUN_STATE_ACTION_DOWN, CANNED_MESSAGE_RUN_STATE_ACTION_DOWN,
CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION, CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION,
CANNED_MESSAGE_RUN_STATE_FREETEXT, CANNED_MESSAGE_RUN_STATE_FREETEXT,
CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION,
CANNED_MESSAGE_RUN_STATE_EMOTE_PICKER
}; };
enum CannedMessageModuleIconType { shift, backspace, space, enter }; enum CannedMessageModuleIconType { shift, backspace, space, enter };
@ -57,6 +58,9 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
public: public:
CannedMessageModule(); CannedMessageModule();
// === Emote Picker navigation ===
int emotePickerIndex = 0; // Tracks currently selected emote in the picker
// === Message navigation === // === Message navigation ===
const char *getCurrentMessage(); const char *getCurrentMessage();
const char *getPrevMessage(); const char *getPrevMessage();
@ -75,6 +79,10 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
bool isCharInputAllowed() const; bool isCharInputAllowed() const;
String drawWithCursor(String text, int cursor); String drawWithCursor(String text, int cursor);
// === Emote Picker ===
int handleEmotePickerInput(const InputEvent *event);
void drawEmotePickerScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
// === Admin Handlers === // === Admin Handlers ===
void handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response); void handleGetCannedMessageModuleMessages(const meshtastic_MeshPacket &req, meshtastic_AdminMessage *response);
void handleSetCannedMessageModuleMessages(const char *from_msg); void handleSetCannedMessageModuleMessages(const char *from_msg);