First limited emoji support!

This commit is contained in:
pelgraine
2026-02-10 00:30:03 +11:00
parent a0fef8a970
commit cca984be08
6 changed files with 716 additions and 54 deletions

View File

@@ -1,4 +1,6 @@
{ {
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [ "recommendations": [
"pioarduino.pioarduino-ide", "pioarduino.pioarduino-ide",
"platformio.platformio-ide" "platformio.platformio-ide"

View File

@@ -8,11 +8,11 @@
#define FIRMWARE_VER_CODE 8 #define FIRMWARE_VER_CODE 8
#ifndef FIRMWARE_BUILD_DATE #ifndef FIRMWARE_BUILD_DATE
#define FIRMWARE_BUILD_DATE "9 Feb 2026" #define FIRMWARE_BUILD_DATE "10 Feb 2026"
#endif #endif
#ifndef FIRMWARE_VERSION #ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "Meck v0.7.3" #define FIRMWARE_VERSION "Meck v0.8"
#endif #endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)

View File

@@ -15,19 +15,25 @@
// Compose mode state // Compose mode state
static bool composeMode = false; static bool composeMode = false;
static char composeBuffer[138]; // 137 chars max + null terminator static char composeBuffer[138]; // 137 bytes max + null terminator (matches BLE wire cost)
static int composePos = 0; static int composePos = 0; // Current wire-cost byte count
static uint8_t composeChannelIdx = 0; // Which channel to send to static uint8_t composeChannelIdx = 0;
static unsigned long lastComposeRefresh = 0; static unsigned long lastComposeRefresh = 0;
static bool composeNeedsRefresh = false; static bool composeNeedsRefresh = false;
#define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms) #define COMPOSE_REFRESH_INTERVAL 600 // ms between e-ink refreshes while typing (refresh takes ~650ms)
// Emoji picker state
#include "EmojiPicker.h"
static bool emojiPickerMode = false;
static EmojiPicker emojiPicker;
// Text reader mode state // Text reader mode state
static bool readerMode = false; static bool readerMode = false;
void initKeyboard(); void initKeyboard();
void handleKeyboardInput(); void handleKeyboardInput();
void drawComposeScreen(); void drawComposeScreen();
void drawEmojiPicker();
void sendComposedMessage(); void sendComposedMessage();
#endif #endif
@@ -376,9 +382,13 @@ void loop() {
if (!composeMode) { if (!composeMode) {
ui_task.loop(); ui_task.loop();
} else { } else {
// Handle debounced compose screen refresh // Handle debounced compose/emoji picker screen refresh
if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) { if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) {
drawComposeScreen(); if (emojiPickerMode) {
drawEmojiPicker();
} else {
drawComposeScreen();
}
lastComposeRefresh = millis(); lastComposeRefresh = millis();
composeNeedsRefresh = false; composeNeedsRefresh = false;
} }
@@ -427,6 +437,37 @@ void handleKeyboardInput() {
key >= 32 ? key : '?', key, composeMode); key >= 32 ? key : '?', key, composeMode);
if (composeMode) { if (composeMode) {
// Emoji picker sub-mode
if (emojiPickerMode) {
uint8_t result = emojiPicker.handleInput(key);
if (result == 0xFF) {
// Cancelled - immediate draw to return to compose
emojiPickerMode = false;
drawComposeScreen();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
} else if (result >= EMOJI_ESCAPE_START && result <= EMOJI_ESCAPE_END) {
// Emoji selected - insert escape byte + padding to match UTF-8 wire cost
int cost = emojiUtf8Cost(result);
if (composePos + cost <= 137) {
composeBuffer[composePos++] = (char)result;
for (int p = 1; p < cost; p++) {
composeBuffer[composePos++] = (char)EMOJI_PAD_BYTE;
}
composeBuffer[composePos] = '\0';
Serial.printf("Compose: Inserted emoji 0x%02X cost=%d, pos=%d\n", result, cost, composePos);
}
emojiPickerMode = false;
drawComposeScreen();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
} else {
// Navigation - debounce (don't draw immediately, let loop handle it)
composeNeedsRefresh = true;
}
return;
}
// In compose mode - handle text input // In compose mode - handle text input
if (key == '\r') { if (key == '\r') {
// Enter - send the message // Enter - send the message
@@ -435,6 +476,7 @@ void handleKeyboardInput() {
sendComposedMessage(); sendComposedMessage();
} }
composeMode = false; composeMode = false;
emojiPickerMode = false;
composeBuffer[0] = '\0'; composeBuffer[0] = '\0';
composePos = 0; composePos = 0;
ui_task.gotoHomeScreen(); ui_task.gotoHomeScreen();
@@ -447,16 +489,24 @@ void handleKeyboardInput() {
// Shift+Backspace = Cancel (works anytime) // Shift+Backspace = Cancel (works anytime)
Serial.println("Compose: Shift+Backspace, cancelling..."); Serial.println("Compose: Shift+Backspace, cancelling...");
composeMode = false; composeMode = false;
emojiPickerMode = false;
composeBuffer[0] = '\0'; composeBuffer[0] = '\0';
composePos = 0; composePos = 0;
ui_task.gotoHomeScreen(); ui_task.gotoHomeScreen();
return; return;
} }
// Regular backspace - delete last character // Regular backspace - delete last character (or entire emoji including pads)
if (composePos > 0) { if (composePos > 0) {
composePos--; // Delete trailing pad bytes first, then the escape byte
while (composePos > 0 && (uint8_t)composeBuffer[composePos - 1] == EMOJI_PAD_BYTE) {
composePos--;
}
// Now delete the actual character (escape byte or regular char)
if (composePos > 0) {
composePos--;
}
composeBuffer[composePos] = '\0'; composeBuffer[composePos] = '\0';
Serial.printf("Compose: Backspace, pos now %d\n", composePos); Serial.printf("Compose: Backspace, pos=%d\n", composePos);
composeNeedsRefresh = true; // Use debounced refresh composeNeedsRefresh = true; // Use debounced refresh
} }
return; return;
@@ -478,7 +528,7 @@ void handleKeyboardInput() {
} }
} }
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx); Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
drawComposeScreen(); composeNeedsRefresh = true; // Debounced refresh
return; return;
} }
@@ -492,7 +542,17 @@ void handleKeyboardInput() {
composeChannelIdx = 0; // Wrap to first channel composeChannelIdx = 0; // Wrap to first channel
} }
Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx); Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx);
drawComposeScreen(); composeNeedsRefresh = true; // Debounced refresh
return;
}
// '$' opens emoji picker
if (key == '$') {
emojiPicker.reset();
emojiPickerMode = true;
drawEmojiPicker();
lastComposeRefresh = millis();
composeNeedsRefresh = false;
return; return;
} }
@@ -500,7 +560,7 @@ void handleKeyboardInput() {
if (key >= 32 && key < 127 && composePos < 137) { if (key >= 32 && key < 127 && composePos < 137) {
composeBuffer[composePos++] = key; composeBuffer[composePos++] = key;
composeBuffer[composePos] = '\0'; composeBuffer[composePos] = '\0';
Serial.printf("Compose: Added '%c', pos now %d\n", key, composePos); Serial.printf("Compose: Added '%c', pos=%d\n", key, composePos);
composeNeedsRefresh = true; // Use debounced refresh composeNeedsRefresh = true; // Use debounced refresh
} }
return; return;
@@ -532,6 +592,7 @@ void handleKeyboardInput() {
composePos = 0; composePos = 0;
Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx); Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx);
drawComposeScreen(); drawComposeScreen();
lastComposeRefresh = millis();
return; return;
} }
@@ -554,6 +615,7 @@ void handleKeyboardInput() {
} }
Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx);
drawComposeScreen(); drawComposeScreen();
lastComposeRefresh = millis();
break; break;
case 'm': case 'm':
@@ -664,22 +726,42 @@ void drawComposeScreen() {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
// Word wrap the compose buffer - calculate chars per line based on actual font width // Word wrap the compose buffer - calculate chars per line based on actual font width
int x = 0;
int y = 14; int y = 14;
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM"); // 10 wide chars
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 20;
if (charsPerLine < 12) charsPerLine = 12;
if (charsPerLine > 40) charsPerLine = 40;
char charStr[2] = {0, 0}; // Buffer for single character as string char charStr[2] = {0, 0}; // Buffer for single character as string
// Track position in pixels for tight emoji placement
int px = 0; // Current pixel X position
int lineW = display.width();
for (int i = 0; i < composePos; i++) { for (int i = 0; i < composePos; i++) {
charStr[0] = composeBuffer[i]; uint8_t b = (uint8_t)composeBuffer[i];
display.print(charStr);
x++; // Skip emoji padding bytes (used to match wire cost)
if (x >= charsPerLine) { if (b == EMOJI_PAD_BYTE) continue;
x = 0;
y += 11; if (isEmojiEscape(b)) {
display.setCursor(0, y); // Check if emoji fits on this line
if (px + EMOJI_LG_W > lineW) {
px = 0;
y += 11;
display.setCursor(0, y);
}
const uint8_t* sprite = getEmojiSpriteLg(b);
if (sprite) {
display.drawXbm(px, y - 1, sprite, EMOJI_LG_W, EMOJI_LG_H);
}
px += EMOJI_LG_W + 1; // sprite width + 1px gap
display.setCursor(px, y);
} else {
charStr[0] = (char)b;
int cw = display.getTextWidth(charStr);
if (px + cw > lineW) {
px = 0;
y += 11;
display.setCursor(0, y);
}
display.print(charStr);
px += cw;
} }
} }
@@ -696,13 +778,13 @@ void drawComposeScreen() {
char status[40]; char status[40];
if (composePos == 0) { if (composePos == 0) {
// Empty buffer - show channel switching hint // Empty buffer - show channel switching hint
display.print("A/D:Ch"); display.print("A/D:Ch $:Emoji");
sprintf(status, "Sh+Del:X"); sprintf(status, "Sh+Del:X");
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
display.print(status); display.print(status);
} else { } else {
// Has text - show send/cancel hint // Has text - show send/cancel hint
sprintf(status, "%d/137 Ent:Send", composePos); sprintf(status, "%d/137 $:Emj", composePos);
display.print(status); display.print(status);
sprintf(status, "Sh+Del:X"); sprintf(status, "Sh+Del:X");
display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY);
@@ -713,6 +795,14 @@ void drawComposeScreen() {
#endif #endif
} }
void drawEmojiPicker() {
#ifdef DISPLAY_CLASS
display.startFrame();
emojiPicker.draw(display);
display.endFrame();
#endif
}
void sendComposedMessage() { void sendComposedMessage() {
if (composePos == 0) return; if (composePos == 0) return;
@@ -721,19 +811,25 @@ void sendComposedMessage() {
if (the_mesh.getChannel(composeChannelIdx, channel)) { if (the_mesh.getChannel(composeChannelIdx, channel)) {
uint32_t timestamp = rtc_clock.getCurrentTime(); uint32_t timestamp = rtc_clock.getCurrentTime();
// Send to channel // Convert escape bytes back to UTF-8 for mesh transmission and BLE app
// Worst case: each escape byte → 8 bytes UTF-8 (flag emoji), plus ASCII chars
char utf8Buf[512];
emojiUnescape(composeBuffer, utf8Buf, sizeof(utf8Buf));
int utf8Len = strlen(utf8Buf);
// Send UTF-8 version to mesh (so other devices/apps see real emoji)
if (the_mesh.sendGroupMessage(timestamp, channel.channel, if (the_mesh.sendGroupMessage(timestamp, channel.channel,
the_mesh.getNodePrefs()->node_name, the_mesh.getNodePrefs()->node_name,
composeBuffer, composePos)) { utf8Buf, utf8Len)) {
// Add the sent message to local channel history so we can see what we sent // Add escape-byte version to local display (renders as sprites)
ui_task.addSentChannelMessage(composeChannelIdx, ui_task.addSentChannelMessage(composeChannelIdx,
the_mesh.getNodePrefs()->node_name, the_mesh.getNodePrefs()->node_name,
composeBuffer); composeBuffer);
// Queue message for BLE app sync (so sent messages appear in companion app) // Queue UTF-8 version for BLE app sync (so companion app shows real emoji)
the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp, the_mesh.queueSentChannelMessage(composeChannelIdx, timestamp,
the_mesh.getNodePrefs()->node_name, the_mesh.getNodePrefs()->node_name,
composeBuffer); utf8Buf);
ui_task.showAlert("Sent!", 1500); ui_task.showAlert("Sent!", 1500);
} else { } else {

View File

@@ -4,6 +4,7 @@
#include <helpers/ui/DisplayDriver.h> #include <helpers/ui/DisplayDriver.h>
#include <helpers/ChannelDetails.h> #include <helpers/ChannelDetails.h>
#include <MeshCore.h> #include <MeshCore.h>
#include "EmojiSprites.h"
// Maximum messages to store in history // Maximum messages to store in history
#define CHANNEL_MSG_HISTORY_SIZE 20 #define CHANNEL_MSG_HISTORY_SIZE 20
@@ -59,9 +60,9 @@ public:
msg->channel_idx = channel_idx; msg->channel_idx = channel_idx;
msg->valid = true; msg->valid = true;
// The text already contains "Sender: message" format, just store it // Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes
strncpy(msg->text, text, CHANNEL_MSG_TEXT_LEN - 1); // The text already contains "Sender: message" format
msg->text[CHANNEL_MSG_TEXT_LEN - 1] = '\0'; emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN);
if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) { if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) {
_msgCount++; _msgCount++;
@@ -132,12 +133,6 @@ public:
// This ensures rendered glyphs (which extend lineHeight below y) stay above the footer // This ensures rendered glyphs (which extend lineHeight below y) stay above the footer
int maxY = display.height() - footerHeight; int maxY = display.height() - footerHeight;
// Calculate chars per line based on actual font width
uint16_t testWidth = display.getTextWidth("MMMMMMMMMM"); // 10 wide chars
int charsPerLine = (testWidth > 0) ? (display.width() * 10) / testWidth : 35;
if (charsPerLine < 20) charsPerLine = 20; // Minimum reasonable
if (charsPerLine > 60) charsPerLine = 60; // Maximum reasonable
int y = headerHeight; int y = headerHeight;
// Build list of messages for this channel (newest first) // Build list of messages for this channel (newest first)
@@ -175,39 +170,63 @@ public:
sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400); sprintf(tmp, "(%d) %dd ", msg->path_len == 0xFF ? 0 : msg->path_len, age / 86400);
} }
display.print(tmp); display.print(tmp);
int prefixLen = strlen(tmp); // Characters used by timestamp on first line
// DO NOT advance y - message text continues on the same line // DO NOT advance y - message text continues on the same line
// Message text with character wrapping (continues after timestamp on first line) // Message text with character wrapping and inline emoji support
// (continues after timestamp on first line)
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
int textLen = strlen(msg->text); int textLen = strlen(msg->text);
int pos = 0; int pos = 0;
int linesForThisMsg = 0; int linesForThisMsg = 0;
int maxLinesPerMsg = 8; int maxLinesPerMsg = 8;
int x = prefixLen; // First line starts after the timestamp prefix
char charStr[2] = {0, 0}; char charStr[2] = {0, 0};
// Track position in pixels for tight emoji placement
int lineW = display.width();
int px = display.getTextWidth(tmp); // Pixel X after timestamp
// Cursor already positioned after timestamp print - don't reset it // Cursor already positioned after timestamp print - don't reset it
while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) { while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
charStr[0] = msg->text[pos]; uint8_t b = (uint8_t)msg->text[pos];
display.print(charStr);
x++;
pos++;
if (x >= charsPerLine) { if (isEmojiEscape(b)) {
x = 0; // Check if emoji fits on this line
linesForThisMsg++; if (px + EMOJI_SM_W > lineW) {
y += lineHeight; px = 0;
if (linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) { linesForThisMsg++;
y += lineHeight;
if (linesForThisMsg >= maxLinesPerMsg || y + lineHeight > maxY) break;
display.setCursor(0, y); display.setCursor(0, y);
} }
const uint8_t* sprite = getEmojiSpriteSm(b);
if (sprite) {
display.drawXbm(px, y - 1, sprite, EMOJI_SM_W, EMOJI_SM_H);
}
pos++;
px += EMOJI_SM_W + 1; // sprite width + 1px gap
display.setCursor(px, y);
} else {
charStr[0] = (char)b;
int cw = display.getTextWidth(charStr);
if (px + cw > lineW) {
px = 0;
linesForThisMsg++;
y += lineHeight;
if (linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) {
display.setCursor(0, y);
} else break;
}
display.print(charStr);
px += cw;
pos++;
} }
} }
// If we didn't end on a full line, still count it // If we didn't end on a full line, still count it
if (x > 0) { if (px > 0) {
y += lineHeight; y += lineHeight;
} }

View File

@@ -0,0 +1,410 @@
#pragma once
// Emoji sprites for e-ink display - dual size
// Large (12x12) for compose/picker, Small (10x10) for channel view
// MSB-first, 2 bytes per row
#include <stdint.h>
#ifdef ESP32
#include <pgmspace.h>
#endif
// Large sprites for compose screen and picker
#define EMOJI_LG_W 12
#define EMOJI_LG_H 12
// Small sprites for channel message view
#define EMOJI_SM_W 10
#define EMOJI_SM_H 10
#define EMOJI_COUNT 20
// Escape codes used in sanitized message text
// Bytes 0x01 through 0x14 map to emoji indices 0-19
#define EMOJI_ESCAPE_START 0x01
#define EMOJI_ESCAPE_END 0x14
#define EMOJI_PAD_BYTE 0x15 // Padding byte after escape to fill buffer to UTF-8 wire cost
// ======== LARGE 12x12 SPRITES ========
static const uint8_t emoji_lg_wireless[] PROGMEM = {
0x00,0x00, 0x3F,0xC0, 0x60,0x60, 0xC0,0x30,
0x0F,0x00, 0x19,0x80, 0x30,0xC0, 0x00,0x00,
0x06,0x00, 0x0F,0x00, 0x06,0x00, 0x00,0x00,
};
static const uint8_t emoji_lg_infinity[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x61,0x80, 0x92,0x40,
0x8C,0x40, 0x8C,0x40, 0x92,0x40, 0x61,0x80,
0x00,0x00, 0x00,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_lg_trex[] PROGMEM = {
0x03,0xE0, 0x06,0xA0, 0x07,0xE0, 0x0C,0x00,
0x5C,0x00, 0x7C,0x00, 0x3C,0x00, 0x38,0x00,
0x3C,0x00, 0x36,0x00, 0x22,0x00, 0x33,0x00,
};
static const uint8_t emoji_lg_skull[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0,
0x40,0x20, 0x49,0x20, 0x2F,0x40, 0x1F,0x80,
0x96,0x90, 0x66,0x60, 0x36,0xC0, 0x96,0x90,
};
static const uint8_t emoji_lg_cross[] PROGMEM = {
0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x3F,0xC0,
0x3F,0xC0, 0x3F,0xC0, 0x0F,0x00, 0x0F,0x00,
0x0F,0x00, 0x0F,0x00, 0x0F,0x00, 0x0F,0x00,
};
static const uint8_t emoji_lg_lightning[] PROGMEM = {
0x03,0x00, 0x07,0x00, 0x0E,0x00, 0x1C,0x00,
0x3F,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00,
0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x00,0x00,
};
static const uint8_t emoji_lg_tophat[] PROGMEM = {
0x00,0x00, 0x1F,0x80, 0x3F,0xC0, 0x3F,0xC0,
0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0, 0x3F,0xC0,
0x7F,0xE0, 0xFF,0xF0, 0xFF,0xF0, 0x00,0x00,
};
static const uint8_t emoji_lg_motorcycle[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x03,0x80, 0x1F,0xC0,
0x3F,0xC0, 0x7F,0xC0, 0xFF,0xE0, 0xDF,0x60,
0x51,0x40, 0xE0,0xE0, 0x40,0x40, 0x00,0x00,
};
static const uint8_t emoji_lg_seedling[] PROGMEM = {
0x00,0x00, 0x30,0x00, 0x79,0x80, 0x7B,0xC0,
0x33,0xC0, 0x1F,0x80, 0x06,0x00, 0x06,0x00,
0x06,0x00, 0x06,0x00, 0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_lg_flag_au[] PROGMEM = {
0x00,0x00, 0x32,0x40, 0x4A,0x40, 0x4A,0x40,
0x7A,0x40, 0x4A,0x40, 0x49,0x80, 0x00,0x00,
0xFF,0xF0, 0x00,0x00, 0xFF,0xF0, 0x00,0x00,
};
static const uint8_t emoji_lg_umbrella[] PROGMEM = {
0x06,0x00, 0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0,
0xFF,0xF0, 0xDB,0x70, 0x06,0x00, 0x06,0x00,
0x06,0x00, 0x06,0x00, 0x46,0x00, 0x3C,0x00,
};
static const uint8_t emoji_lg_nazar[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x4F,0x20, 0x99,0x90,
0xB6,0xD0, 0xB6,0xD0, 0xB6,0xD0, 0x99,0x90,
0x4F,0x20, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
static const uint8_t emoji_lg_globe[] PROGMEM = {
0x1F,0x80, 0x34,0xC0, 0x66,0x60, 0x4F,0x20,
0x8E,0x10, 0x86,0x10, 0x80,0x30, 0x46,0x60,
0x43,0xE0, 0x30,0xC0, 0x1F,0x80, 0x00,0x00,
};
static const uint8_t emoji_lg_radioactive[] PROGMEM = {
0x00,0x00, 0x22,0x40, 0x32,0xC0, 0x32,0xC0,
0x1B,0x40, 0x00,0x00, 0x0F,0x00, 0x0F,0x00,
0x00,0x00, 0x60,0x20, 0x39,0xC0, 0x0F,0x00,
};
static const uint8_t emoji_lg_cow[] PROGMEM = {
0x00,0x00, 0xC0,0x60, 0x6E,0xC0, 0x3F,0x80,
0x2A,0x80, 0x3F,0x80, 0x3F,0x80, 0x7F,0xC0,
0x5F,0x40, 0x5F,0x40, 0x11,0x00, 0x31,0x80,
};
static const uint8_t emoji_lg_alien[] PROGMEM = {
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0x76,0xE0,
0xF6,0xF0, 0x96,0x90, 0x7F,0xE0, 0x36,0xC0,
0x3F,0xC0, 0x16,0x80, 0x0F,0x00, 0x06,0x00,
};
static const uint8_t emoji_lg_invader[] PROGMEM = {
0x10,0x80, 0x09,0x00, 0x1F,0x80, 0x36,0xC0,
0x7F,0xE0, 0x5F,0xA0, 0x50,0xA0, 0x50,0xA0,
0x19,0x80, 0x19,0x80, 0x30,0xC0, 0x00,0x00,
};
static const uint8_t emoji_lg_dagger[] PROGMEM = {
0x00,0x20, 0x00,0x60, 0x00,0xC0, 0x01,0x80,
0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00,
0x38,0x00, 0x58,0x00, 0x28,0x00, 0x18,0x00,
};
static const uint8_t emoji_lg_grimace[] PROGMEM = {
0x1F,0x80, 0x20,0x40, 0x59,0xA0, 0x59,0xA0,
0x40,0x20, 0x40,0x20, 0x5F,0xA0, 0x55,0x40,
0x5F,0xA0, 0x20,0x40, 0x1F,0x80, 0x00,0x00,
};
static const uint8_t emoji_lg_telephone[] PROGMEM = {
0x00,0x00, 0x7F,0xE0, 0xC0,0x30, 0xC0,0x30,
0x60,0x60, 0x30,0xC0, 0x1F,0x80, 0x0F,0x00,
0x1F,0x80, 0x3F,0xC0, 0x7F,0xE0, 0x00,0x00,
};
static const uint8_t* const EMOJI_SPRITES_LG[] PROGMEM = {
emoji_lg_wireless, emoji_lg_infinity, emoji_lg_trex, emoji_lg_skull, emoji_lg_cross,
emoji_lg_lightning, emoji_lg_tophat, emoji_lg_motorcycle, emoji_lg_seedling, emoji_lg_flag_au,
emoji_lg_umbrella, emoji_lg_nazar, emoji_lg_globe, emoji_lg_radioactive, emoji_lg_cow,
emoji_lg_alien, emoji_lg_invader, emoji_lg_dagger, emoji_lg_grimace, emoji_lg_telephone,
};
// ======== SMALL 10x10 SPRITES ========
static const uint8_t emoji_sm_wireless[] PROGMEM = {
0x00,0x00, 0x7F,0x80, 0xC0,0xC0, 0x1E,0x00,
0x33,0x00, 0x21,0x00, 0x00,0x00, 0x0C,0x00,
0x0C,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_infinity[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0xE7,0x00, 0x99,0x00,
0x99,0x00, 0xA5,0x00, 0x42,0x00, 0x00,0x00,
0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_trex[] PROGMEM = {
0x07,0x80, 0x0F,0x80, 0x0F,0x80, 0x58,0x00,
0x78,0x00, 0x38,0x00, 0x38,0x00, 0x3C,0x00,
0x24,0x00, 0x26,0x00,
};
static const uint8_t emoji_sm_skull[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80,
0x52,0x80, 0x3F,0x00, 0x3F,0x00, 0xED,0xC0,
0x6D,0x80, 0xAD,0x40,
};
static const uint8_t emoji_sm_cross[] PROGMEM = {
0x1E,0x00, 0x1E,0x00, 0x3F,0x00, 0x3F,0x00,
0x3F,0x00, 0x1E,0x00, 0x1E,0x00, 0x1E,0x00,
0x1E,0x00, 0x1E,0x00,
};
static const uint8_t emoji_sm_lightning[] PROGMEM = {
0x06,0x00, 0x0E,0x00, 0x1C,0x00, 0x3E,0x00,
0x03,0x00, 0x06,0x00, 0x0C,0x00, 0x18,0x00,
0x30,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_tophat[] PROGMEM = {
0x00,0x00, 0x3F,0x00, 0x3F,0x00, 0x3F,0x00,
0x3F,0x00, 0x3F,0x00, 0x7F,0x80, 0xFF,0xC0,
0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_motorcycle[] PROGMEM = {
0x00,0x00, 0x00,0x00, 0x1F,0x00, 0x3F,0x00,
0x7F,0x00, 0xFF,0x80, 0xFF,0x80, 0xE3,0x80,
0xC1,0x80, 0x00,0x00,
};
static const uint8_t emoji_sm_seedling[] PROGMEM = {
0x00,0x00, 0x70,0x00, 0x77,0x00, 0x77,0x00,
0x3F,0x00, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00,
0x00,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_flag_au[] PROGMEM = {
0x00,0x00, 0x75,0x00, 0x55,0x00, 0x75,0x00,
0x55,0x00, 0x53,0x00, 0x00,0x00, 0xFF,0xC0,
0xFF,0xC0, 0x00,0x00,
};
static const uint8_t emoji_sm_umbrella[] PROGMEM = {
0x0C,0x00, 0x3F,0x00, 0x7F,0x80, 0xFF,0xC0,
0xF7,0xC0, 0x0C,0x00, 0x0C,0x00, 0x0C,0x00,
0x4C,0x00, 0x78,0x00,
};
static const uint8_t emoji_sm_nazar[] PROGMEM = {
0x3F,0x00, 0x40,0x80, 0x9E,0x40, 0xBF,0x40,
0xAD,0x40, 0xBF,0x40, 0x9E,0x40, 0x4C,0x80,
0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_globe[] PROGMEM = {
0x3F,0x00, 0x69,0x80, 0x4C,0x80, 0x9C,0x40,
0x8C,0x40, 0x80,0xC0, 0x4D,0x80, 0x67,0x80,
0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_radioactive[] PROGMEM = {
0x00,0x00, 0x25,0x00, 0x25,0x00, 0x37,0x00,
0x00,0x00, 0x1E,0x00, 0x1E,0x00, 0x40,0x00,
0x73,0x80, 0x1E,0x00,
};
static const uint8_t emoji_sm_cow[] PROGMEM = {
0x00,0x00, 0xC1,0x80, 0x7F,0x00, 0x3F,0x00,
0x3F,0x00, 0x7F,0x00, 0x7F,0x00, 0x7F,0x00,
0x36,0x00, 0x23,0x00,
};
static const uint8_t emoji_sm_alien[] PROGMEM = {
0x3F,0x00, 0x7F,0x80, 0x7F,0x80, 0xED,0xC0,
0xAD,0x40, 0x7F,0x80, 0x3F,0x00, 0x3F,0x00,
0x1E,0x00, 0x0C,0x00,
};
static const uint8_t emoji_sm_invader[] PROGMEM = {
0x33,0x00, 0x1E,0x00, 0x3F,0x00, 0x7F,0x80,
0x7F,0x80, 0x61,0x80, 0x73,0x80, 0x33,0x00,
0x33,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_dagger[] PROGMEM = {
0x00,0x80, 0x01,0x80, 0x03,0x00, 0x06,0x00,
0x0C,0x00, 0x18,0x00, 0x30,0x00, 0x70,0x00,
0x70,0x00, 0x30,0x00,
};
static const uint8_t emoji_sm_grimace[] PROGMEM = {
0x3F,0x00, 0x61,0x80, 0x73,0x80, 0x40,0x80,
0x40,0x80, 0x7F,0x80, 0x55,0x00, 0x7F,0x80,
0x3F,0x00, 0x00,0x00,
};
static const uint8_t emoji_sm_telephone[] PROGMEM = {
0x00,0x00, 0xFF,0xC0, 0xC0,0xC0, 0xC0,0xC0,
0x61,0x80, 0x3F,0x00, 0x1E,0x00, 0x3F,0x00,
0x7F,0x80, 0x00,0x00,
};
static const uint8_t* const EMOJI_SPRITES_SM[] PROGMEM = {
emoji_sm_wireless, emoji_sm_infinity, emoji_sm_trex, emoji_sm_skull, emoji_sm_cross,
emoji_sm_lightning, emoji_sm_tophat, emoji_sm_motorcycle, emoji_sm_seedling, emoji_sm_flag_au,
emoji_sm_umbrella, emoji_sm_nazar, emoji_sm_globe, emoji_sm_radioactive, emoji_sm_cow,
emoji_sm_alien, emoji_sm_invader, emoji_sm_dagger, emoji_sm_grimace, emoji_sm_telephone,
};
// ---- Codepoint lookup for UTF-8 detection ----
struct EmojiCodepoint {
uint32_t cp;
uint32_t cp2;
uint8_t escape;
};
static const EmojiCodepoint EMOJI_CODEPOINTS[20] = {
{ 0x1F6DC, 0x0000, 0x01 }, { 0x267E, 0x0000, 0x02 }, { 0x1F996, 0x0000, 0x03 },
{ 0x2620, 0x0000, 0x04 }, { 0x271D, 0x0000, 0x05 }, { 0x26A1, 0x0000, 0x06 },
{ 0x1F3A9, 0x0000, 0x07 }, { 0x1F3CD, 0x0000, 0x08 }, { 0x1F331, 0x0000, 0x09 },
{ 0x1F1E6, 0x1F1FA, 0x0A }, { 0x2602, 0x0000, 0x0B }, { 0x1F9FF, 0x0000, 0x0C },
{ 0x1F30F, 0x0000, 0x0D }, { 0x2622, 0x0000, 0x0E }, { 0x1F404, 0x0000, 0x0F },
{ 0x1F47D, 0x0000, 0x10 }, { 0x1F47E, 0x0000, 0x11 }, { 0x1F5E1, 0x0000, 0x12 },
{ 0x1F62C, 0x0000, 0x13 }, { 0x260E, 0x0000, 0x14 },
};
// ---- Helper functions ----
static uint32_t emojiDecodeUtf8(const uint8_t* s, int remaining, int* bytes_consumed) {
uint8_t b0 = s[0];
if (b0 < 0x80) { *bytes_consumed = 1; return b0; }
if ((b0 & 0xE0) == 0xC0 && remaining >= 2) {
*bytes_consumed = 2;
return ((uint32_t)(b0 & 0x1F) << 6) | (s[1] & 0x3F);
}
if ((b0 & 0xF0) == 0xE0 && remaining >= 3) {
*bytes_consumed = 3;
return ((uint32_t)(b0 & 0x0F) << 12) | ((uint32_t)(s[1] & 0x3F) << 6) | (s[2] & 0x3F);
}
if ((b0 & 0xF8) == 0xF0 && remaining >= 4) {
*bytes_consumed = 4;
return ((uint32_t)(b0 & 0x07) << 18) | ((uint32_t)(s[1] & 0x3F) << 12) | ((uint32_t)(s[2] & 0x3F) << 6) | (s[3] & 0x3F);
}
*bytes_consumed = 1;
return 0xFFFD;
}
static void emojiSanitize(const char* src, char* dst, int dstLen) {
const uint8_t* s = (const uint8_t*)src;
int si = 0, di = 0;
int srcLen = strlen(src);
while (si < srcLen && di < dstLen - 1) {
uint8_t b = s[si];
if (b >= 0xE0) {
int consumed;
uint32_t cp = emojiDecodeUtf8(s + si, srcLen - si, &consumed);
if (cp == 0xFE0F) { si += consumed; continue; }
bool found = false;
for (int e = 0; e < 20; e++) {
if (EMOJI_CODEPOINTS[e].cp == cp) {
if (EMOJI_CODEPOINTS[e].cp2 != 0) {
int consumed2;
if (si + consumed < srcLen) {
uint32_t cp2 = emojiDecodeUtf8(s + si + consumed, srcLen - si - consumed, &consumed2);
if (cp2 == EMOJI_CODEPOINTS[e].cp2) {
dst[di++] = EMOJI_CODEPOINTS[e].escape;
si += consumed + consumed2;
found = true; break;
}
}
continue;
}
dst[di++] = EMOJI_CODEPOINTS[e].escape;
si += consumed;
if (si + 2 < srcLen && s[si] == 0xEF && s[si+1] == 0xB8 && s[si+2] == 0x8F) si += 3;
found = true; break;
}
}
if (!found) si += consumed;
} else {
dst[di++] = (char)b;
si++;
}
}
dst[di] = '\0';
}
static inline bool isEmojiEscape(uint8_t b) {
return b >= EMOJI_ESCAPE_START && b <= EMOJI_ESCAPE_END;
}
// Encode a Unicode codepoint as UTF-8 into dst, return bytes written
static int emojiEncodeUtf8(uint32_t cp, uint8_t* dst) {
if (cp < 0x80) {
dst[0] = (uint8_t)cp;
return 1;
} else if (cp < 0x800) {
dst[0] = 0xC0 | (cp >> 6);
dst[1] = 0x80 | (cp & 0x3F);
return 2;
} else if (cp < 0x10000) {
dst[0] = 0xE0 | (cp >> 12);
dst[1] = 0x80 | ((cp >> 6) & 0x3F);
dst[2] = 0x80 | (cp & 0x3F);
return 3;
} else {
dst[0] = 0xF0 | (cp >> 18);
dst[1] = 0x80 | ((cp >> 12) & 0x3F);
dst[2] = 0x80 | ((cp >> 6) & 0x3F);
dst[3] = 0x80 | (cp & 0x3F);
return 4;
}
}
// Reverse of emojiSanitize: convert escape bytes back to UTF-8 emoji sequences
// Used before sending over mesh/BLE so other devices and apps see real emoji
// dst must be large enough (worst case: srcLen * 8 for all flag emoji)
static void emojiUnescape(const char* src, char* dst, int dstLen) {
int si = 0, di = 0;
int srcLen = strlen(src);
while (si < srcLen && di < dstLen - 1) {
uint8_t b = (uint8_t)src[si];
if (b == EMOJI_PAD_BYTE) {
si++; // Skip padding bytes
continue;
}
if (isEmojiEscape(b)) {
int idx = b - EMOJI_ESCAPE_START;
if (idx < 20) {
uint8_t utf8[8];
int len = emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp, utf8);
// Encode second codepoint if present (flag sequences)
if (EMOJI_CODEPOINTS[idx].cp2 != 0) {
len += emojiEncodeUtf8(EMOJI_CODEPOINTS[idx].cp2, utf8 + len);
}
if (di + len < dstLen) {
memcpy(dst + di, utf8, len);
di += len;
} else break; // No room
}
si++;
} else {
dst[di++] = src[si++];
}
}
dst[di] = '\0';
}
// Get large sprite (for compose/picker)
static inline const uint8_t* getEmojiSpriteLg(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return nullptr;
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[escape_byte - EMOJI_ESCAPE_START]);
}
// Get small sprite (for channel view)
static inline const uint8_t* getEmojiSpriteSm(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return nullptr;
return (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_SM[escape_byte - EMOJI_ESCAPE_START]);
}
// Get the UTF-8 wire cost in bytes for an escape byte (4 for most emoji, 8 for flags)
static inline int emojiUtf8Cost(uint8_t escape_byte) {
if (!isEmojiEscape(escape_byte)) return 1; // not an emoji, regular char
int idx = escape_byte - EMOJI_ESCAPE_START;
uint32_t cp = EMOJI_CODEPOINTS[idx].cp;
int cost = (cp < 0x80) ? 1 : (cp < 0x800) ? 2 : (cp < 0x10000) ? 3 : 4;
if (EMOJI_CODEPOINTS[idx].cp2 != 0) {
uint32_t cp2 = EMOJI_CODEPOINTS[idx].cp2;
cost += (cp2 < 0x80) ? 1 : (cp2 < 0x800) ? 2 : (cp2 < 0x10000) ? 3 : 4;
}
return cost;
}

View File

@@ -0,0 +1,135 @@
#pragma once
// Emoji Picker for compose mode
// Shows a grid of available emoji sprites, navigable with WASD + Enter to select
// Requires EmojiSprites.h to be included before this file
#include <helpers/ui/DisplayDriver.h>
#include "EmojiSprites.h"
// Grid layout: 5 columns x 4 rows = 20 emojis
#define EMOJI_PICKER_COLS 5
#define EMOJI_PICKER_ROWS 4
// Short labels shown alongside sprites for identification
static const char* EMOJI_LABELS[EMOJI_COUNT] = {
"WiFi", // 0 🛜
"Inf", // 1 ♾️
"Rex", // 2 🦖
"Skul", // 3 ☠️
"Cros", // 4 ✝️
"Bolt", // 5 ⚡
"Hat", // 6 🎩
"Moto", // 7 🏍️
"Leaf", // 8 🌱
"AU", // 9 🇦🇺
"Umbr", // 10 ☂️
"Eye", // 11 🧿
"Glob", // 12 🌏
"Rad", // 13 ☢️
"Cow", // 14 🐄
"ET", // 15 👽
"Inv", // 16 👾
"Dagr", // 17 🗡️
"Grim", // 18 😬
"Fone", // 19 ☎️
};
struct EmojiPicker {
int cursor; // 0 to EMOJI_COUNT-1
EmojiPicker() : cursor(0) {}
void reset() { cursor = 0; }
// Returns the emoji escape byte for the selected emoji, or 0 if cancelled
// Navigate: W=up, S=down, A=left, D=right, Enter=select, Backspace/Q/$=cancel
uint8_t handleInput(char key) {
int col = cursor % EMOJI_PICKER_COLS;
int row = cursor / EMOJI_PICKER_COLS;
switch (key) {
case 'w': case 'W': case 0xF2: // Up
if (row > 0) cursor -= EMOJI_PICKER_COLS;
return 0;
case 's': case 'S': case 0xF1: // Down
if (row < EMOJI_PICKER_ROWS - 1) cursor += EMOJI_PICKER_COLS;
return 0;
case 'a': case 'A': // Left
if (col > 0) cursor--;
else if (row > 0) cursor -= 1; // Wrap to end of previous row
return 0;
case 'd': case 'D': // Right
if (cursor + 1 < EMOJI_COUNT) cursor++;
return 0;
case '\r': // Enter - select
return (uint8_t)(EMOJI_ESCAPE_START + cursor);
case '\b': case 'q': case 'Q': case '$': // Cancel
return 0xFF; // Sentinel for "cancelled"
default:
return 0; // No action
}
}
void draw(DisplayDriver& display) {
// Header
display.setTextSize(1);
display.setCursor(0, 0);
display.setColor(DisplayDriver::GREEN);
display.print("Select Emoji");
display.setColor(DisplayDriver::LIGHT);
display.drawRect(0, 11, display.width(), 1);
// Grid area
display.setTextSize(0); // Tiny font for labels
int startY = 14;
int cellW = display.width() / EMOJI_PICKER_COLS;
int cellH = (display.height() - startY - 14) / EMOJI_PICKER_ROWS; // Leave room for footer
for (int i = 0; i < EMOJI_COUNT; i++) {
int col = i % EMOJI_PICKER_COLS;
int row = i / EMOJI_PICKER_COLS;
int cx = col * cellW;
int cy = startY + row * cellH;
// Draw selection border around highlighted cell
if (i == cursor) {
display.setColor(DisplayDriver::LIGHT);
display.drawRect(cx, cy, cellW, cellH);
display.drawRect(cx + 1, cy + 1, cellW - 2, cellH - 2); // Double border for visibility
}
// Draw sprite centered in cell
display.setColor(DisplayDriver::LIGHT);
const uint8_t* sprite = (const uint8_t*)pgm_read_ptr(&EMOJI_SPRITES_LG[i]);
if (sprite) {
int spriteX = cx + (cellW - EMOJI_LG_W) / 2;
int spriteY = cy + 1;
display.drawXbm(spriteX, spriteY, sprite, EMOJI_LG_W, EMOJI_LG_H);
}
// Label below sprite
display.setColor(DisplayDriver::YELLOW);
// Center label text in cell
uint16_t labelW = display.getTextWidth(EMOJI_LABELS[i]);
int labelX = cx + (cellW * 7 - labelW) / (7 * 2); // Approximate centering
display.setCursor(labelX, cy + 12);
display.print(EMOJI_LABELS[i]);
}
// Footer
display.setTextSize(1);
int footerY = display.height() - 12;
display.drawRect(0, footerY - 2, display.width(), 1);
display.setCursor(0, footerY);
display.setColor(DisplayDriver::YELLOW);
display.print("WASD:Nav Ent:Pick");
const char* cancelText = "$:Back";
display.setCursor(display.width() - display.getTextWidth(cancelText) - 2, footerY);
display.print(cancelText);
}
};