diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8057bc7..5c5735c 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,6 @@ { + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format "recommendations": [ "pioarduino.pioarduino-ide", "platformio.platformio-ide" diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index a50a790..1b53e28 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -8,11 +8,11 @@ #define FIRMWARE_VER_CODE 8 #ifndef FIRMWARE_BUILD_DATE -#define FIRMWARE_BUILD_DATE "9 Feb 2026" +#define FIRMWARE_BUILD_DATE "10 Feb 2026" #endif #ifndef FIRMWARE_VERSION -#define FIRMWARE_VERSION "Meck v0.7.3" +#define FIRMWARE_VERSION "Meck v0.8" #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f67bc4b..b1d3ecf 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -15,19 +15,25 @@ // Compose mode state static bool composeMode = false; - static char composeBuffer[138]; // 137 chars max + null terminator - static int composePos = 0; - static uint8_t composeChannelIdx = 0; // Which channel to send to + static char composeBuffer[138]; // 137 bytes max + null terminator (matches BLE wire cost) + static int composePos = 0; // Current wire-cost byte count + static uint8_t composeChannelIdx = 0; static unsigned long lastComposeRefresh = 0; static bool composeNeedsRefresh = false; #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 static bool readerMode = false; void initKeyboard(); void handleKeyboardInput(); void drawComposeScreen(); + void drawEmojiPicker(); void sendComposedMessage(); #endif @@ -376,9 +382,13 @@ void loop() { if (!composeMode) { ui_task.loop(); } else { - // Handle debounced compose screen refresh + // Handle debounced compose/emoji picker screen refresh if (composeNeedsRefresh && (millis() - lastComposeRefresh) >= COMPOSE_REFRESH_INTERVAL) { - drawComposeScreen(); + if (emojiPickerMode) { + drawEmojiPicker(); + } else { + drawComposeScreen(); + } lastComposeRefresh = millis(); composeNeedsRefresh = false; } @@ -427,6 +437,37 @@ void handleKeyboardInput() { key >= 32 ? key : '?', key, 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 if (key == '\r') { // Enter - send the message @@ -435,6 +476,7 @@ void handleKeyboardInput() { sendComposedMessage(); } composeMode = false; + emojiPickerMode = false; composeBuffer[0] = '\0'; composePos = 0; ui_task.gotoHomeScreen(); @@ -447,16 +489,24 @@ void handleKeyboardInput() { // Shift+Backspace = Cancel (works anytime) Serial.println("Compose: Shift+Backspace, cancelling..."); composeMode = false; + emojiPickerMode = false; composeBuffer[0] = '\0'; composePos = 0; ui_task.gotoHomeScreen(); return; } - // Regular backspace - delete last character + // Regular backspace - delete last character (or entire emoji including pads) 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'; - Serial.printf("Compose: Backspace, pos now %d\n", composePos); + Serial.printf("Compose: Backspace, pos=%d\n", composePos); composeNeedsRefresh = true; // Use debounced refresh } return; @@ -478,7 +528,7 @@ void handleKeyboardInput() { } } Serial.printf("Compose: Channel switched to %d\n", composeChannelIdx); - drawComposeScreen(); + composeNeedsRefresh = true; // Debounced refresh return; } @@ -492,7 +542,17 @@ void handleKeyboardInput() { composeChannelIdx = 0; // Wrap to first channel } 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; } @@ -500,7 +560,7 @@ void handleKeyboardInput() { if (key >= 32 && key < 127 && composePos < 137) { composeBuffer[composePos++] = key; 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 } return; @@ -532,6 +592,7 @@ void handleKeyboardInput() { composePos = 0; Serial.printf("Entering compose mode from reader, channel %d\n", composeChannelIdx); drawComposeScreen(); + lastComposeRefresh = millis(); return; } @@ -554,6 +615,7 @@ void handleKeyboardInput() { } Serial.printf("Entering compose mode, channel %d\n", composeChannelIdx); drawComposeScreen(); + lastComposeRefresh = millis(); break; case 'm': @@ -664,22 +726,42 @@ void drawComposeScreen() { display.setColor(DisplayDriver::LIGHT); // Word wrap the compose buffer - calculate chars per line based on actual font width - int x = 0; 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 + // 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++) { - charStr[0] = composeBuffer[i]; - display.print(charStr); - x++; - if (x >= charsPerLine) { - x = 0; - y += 11; - display.setCursor(0, y); + uint8_t b = (uint8_t)composeBuffer[i]; + + // Skip emoji padding bytes (used to match wire cost) + if (b == EMOJI_PAD_BYTE) continue; + + if (isEmojiEscape(b)) { + // 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]; if (composePos == 0) { // Empty buffer - show channel switching hint - display.print("A/D:Ch"); + display.print("A/D:Ch $:Emoji"); sprintf(status, "Sh+Del:X"); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); display.print(status); } else { // Has text - show send/cancel hint - sprintf(status, "%d/137 Ent:Send", composePos); + sprintf(status, "%d/137 $:Emj", composePos); display.print(status); sprintf(status, "Sh+Del:X"); display.setCursor(display.width() - display.getTextWidth(status) - 2, statusY); @@ -713,6 +795,14 @@ void drawComposeScreen() { #endif } +void drawEmojiPicker() { + #ifdef DISPLAY_CLASS + display.startFrame(); + emojiPicker.draw(display); + display.endFrame(); + #endif +} + void sendComposedMessage() { if (composePos == 0) return; @@ -721,19 +811,25 @@ void sendComposedMessage() { if (the_mesh.getChannel(composeChannelIdx, channel)) { 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, the_mesh.getNodePrefs()->node_name, - composeBuffer, composePos)) { - // Add the sent message to local channel history so we can see what we sent + utf8Buf, utf8Len)) { + // Add escape-byte version to local display (renders as sprites) ui_task.addSentChannelMessage(composeChannelIdx, the_mesh.getNodePrefs()->node_name, 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.getNodePrefs()->node_name, - composeBuffer); + utf8Buf); ui_task.showAlert("Sent!", 1500); } else { diff --git a/examples/companion_radio/ui-new/ChannelScreen.h b/examples/companion_radio/ui-new/ChannelScreen.h index 79a9447..f727ddc 100644 --- a/examples/companion_radio/ui-new/ChannelScreen.h +++ b/examples/companion_radio/ui-new/ChannelScreen.h @@ -4,6 +4,7 @@ #include #include #include +#include "EmojiSprites.h" // Maximum messages to store in history #define CHANNEL_MSG_HISTORY_SIZE 20 @@ -59,9 +60,9 @@ public: msg->channel_idx = channel_idx; msg->valid = true; - // The text already contains "Sender: message" format, just store it - strncpy(msg->text, text, CHANNEL_MSG_TEXT_LEN - 1); - msg->text[CHANNEL_MSG_TEXT_LEN - 1] = '\0'; + // Sanitize emoji: replace UTF-8 emoji sequences with single-byte escape codes + // The text already contains "Sender: message" format + emojiSanitize(text, msg->text, CHANNEL_MSG_TEXT_LEN); if (_msgCount < CHANNEL_MSG_HISTORY_SIZE) { _msgCount++; @@ -132,12 +133,6 @@ public: // This ensures rendered glyphs (which extend lineHeight below y) stay above the footer 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; // 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); } 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 - // 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); int textLen = strlen(msg->text); int pos = 0; int linesForThisMsg = 0; int maxLinesPerMsg = 8; - int x = prefixLen; // First line starts after the timestamp prefix 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 while (pos < textLen && linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) { - charStr[0] = msg->text[pos]; - display.print(charStr); - x++; - pos++; + uint8_t b = (uint8_t)msg->text[pos]; - if (x >= charsPerLine) { - x = 0; - linesForThisMsg++; - y += lineHeight; - if (linesForThisMsg < maxLinesPerMsg && y + lineHeight <= maxY) { + if (isEmojiEscape(b)) { + // Check if emoji fits on this line + if (px + EMOJI_SM_W > lineW) { + px = 0; + linesForThisMsg++; + y += lineHeight; + if (linesForThisMsg >= maxLinesPerMsg || y + lineHeight > maxY) break; 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 (x > 0) { + if (px > 0) { y += lineHeight; } diff --git a/examples/companion_radio/ui-new/Emojisprites.h b/examples/companion_radio/ui-new/Emojisprites.h new file mode 100644 index 0000000..8ce25e7 --- /dev/null +++ b/examples/companion_radio/ui-new/Emojisprites.h @@ -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 +#ifdef ESP32 +#include +#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; +} \ No newline at end of file diff --git a/examples/companion_radio/ui-new/emojipicker.h b/examples/companion_radio/ui-new/emojipicker.h new file mode 100644 index 0000000..c796394 --- /dev/null +++ b/examples/companion_radio/ui-new/emojipicker.h @@ -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 +#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); + } +}; \ No newline at end of file