mirror of
https://github.com/meshtastic/firmware.git
synced 2026-03-28 20:13:43 -04:00
BaseUI: Emote Refactoring (#9896)
* Emote refactor for BaseUI * Trunk Check * Copilot suggestions
This commit is contained in:
434
src/graphics/EmoteRenderer.cpp
Normal file
434
src/graphics/EmoteRenderer.cpp
Normal file
@@ -0,0 +1,434 @@
|
||||
#include "configuration.h"
|
||||
#if HAS_SCREEN
|
||||
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
namespace EmoteRenderer
|
||||
{
|
||||
|
||||
static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len)
|
||||
{
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
return display->getStringWidth(text, len, true);
|
||||
#else
|
||||
(void)len;
|
||||
return display->getStringWidth(text);
|
||||
#endif
|
||||
}
|
||||
|
||||
size_t utf8CharLen(uint8_t c)
|
||||
{
|
||||
if ((c & 0xE0) == 0xC0)
|
||||
return 2;
|
||||
if ((c & 0xF0) == 0xE0)
|
||||
return 3;
|
||||
if ((c & 0xF8) == 0xF0)
|
||||
return 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline bool isPossibleEmoteLead(uint8_t c)
|
||||
{
|
||||
// All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
|
||||
return c == 0xE2 || c == 0xF0;
|
||||
}
|
||||
|
||||
static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len)
|
||||
{
|
||||
char chunk[5] = {0, 0, 0, 0, 0};
|
||||
if (len > 4)
|
||||
len = 4;
|
||||
memcpy(chunk, text, len);
|
||||
return getStringWidth(display, chunk, len);
|
||||
}
|
||||
|
||||
static inline bool isFE0FAt(const char *s, size_t pos, size_t len)
|
||||
{
|
||||
return pos + 2 < len && static_cast<uint8_t>(s[pos]) == 0xEF && static_cast<uint8_t>(s[pos + 1]) == 0xB8 &&
|
||||
static_cast<uint8_t>(s[pos + 2]) == 0x8F;
|
||||
}
|
||||
|
||||
static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
|
||||
{
|
||||
return pos + 3 < len && static_cast<uint8_t>(s[pos]) == 0xF0 && static_cast<uint8_t>(s[pos + 1]) == 0x9F &&
|
||||
static_cast<uint8_t>(s[pos + 2]) == 0x8F &&
|
||||
(static_cast<uint8_t>(s[pos + 3]) >= 0xBB && static_cast<uint8_t>(s[pos + 3]) <= 0xBF);
|
||||
}
|
||||
|
||||
static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len)
|
||||
{
|
||||
// Skip modifiers that do not change which bitmap we render.
|
||||
if (isFE0FAt(s, pos, len))
|
||||
return 3;
|
||||
if (isSkinToneAt(s, pos, len))
|
||||
return 4;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount)
|
||||
{
|
||||
if (!label || !*label)
|
||||
return nullptr;
|
||||
|
||||
for (int i = 0; i < emoteCount; ++i) {
|
||||
if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0)
|
||||
return &emoteSet[i];
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed,
|
||||
size_t &matchScore)
|
||||
{
|
||||
// Treat FE0F and skin-tone modifiers as optional while matching.
|
||||
textConsumed = 0;
|
||||
matchScore = 0;
|
||||
if (!label || !*label || pos >= textLen)
|
||||
return false;
|
||||
|
||||
const size_t labelLen = strlen(label);
|
||||
size_t ti = pos;
|
||||
size_t li = 0;
|
||||
|
||||
while (true) {
|
||||
while (ti < textLen) {
|
||||
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
|
||||
if (!skipLen)
|
||||
break;
|
||||
ti += skipLen;
|
||||
}
|
||||
while (li < labelLen) {
|
||||
const size_t skipLen = ignorableModifierLenAt(label, li, labelLen);
|
||||
if (!skipLen)
|
||||
break;
|
||||
li += skipLen;
|
||||
}
|
||||
|
||||
if (li >= labelLen) {
|
||||
while (ti < textLen) {
|
||||
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
|
||||
if (!skipLen)
|
||||
break;
|
||||
ti += skipLen;
|
||||
}
|
||||
textConsumed = ti - pos;
|
||||
return textConsumed > 0;
|
||||
}
|
||||
|
||||
if (ti >= textLen)
|
||||
return false;
|
||||
|
||||
const uint8_t tc = static_cast<uint8_t>(text[ti]);
|
||||
const uint8_t lc = static_cast<uint8_t>(label[li]);
|
||||
const size_t tlen = utf8CharLen(tc);
|
||||
const size_t llen = utf8CharLen(lc);
|
||||
|
||||
if (tlen != llen || ti + tlen > textLen || li + llen > labelLen)
|
||||
return false;
|
||||
if (memcmp(text + ti, label + li, tlen) != 0)
|
||||
return false;
|
||||
|
||||
ti += tlen;
|
||||
li += llen;
|
||||
matchScore += llen;
|
||||
}
|
||||
}
|
||||
|
||||
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount)
|
||||
{
|
||||
// Prefer the longest matching label at this byte offset.
|
||||
const Emote *matched = nullptr;
|
||||
matchLen = 0;
|
||||
size_t bestScore = 0;
|
||||
if (!text || pos >= textLen)
|
||||
return nullptr;
|
||||
|
||||
if (!isPossibleEmoteLead(static_cast<uint8_t>(text[pos])))
|
||||
return nullptr;
|
||||
|
||||
for (int i = 0; i < emoteCount; ++i) {
|
||||
const char *label = emoteSet[i].label;
|
||||
if (!label || !*label)
|
||||
continue;
|
||||
if (static_cast<uint8_t>(label[0]) != static_cast<uint8_t>(text[pos]))
|
||||
continue;
|
||||
|
||||
const size_t labelLen = strlen(label);
|
||||
if (labelLen == 0)
|
||||
continue;
|
||||
|
||||
size_t candidateLen = 0;
|
||||
size_t candidateScore = 0;
|
||||
if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) {
|
||||
candidateLen = labelLen;
|
||||
candidateScore = labelLen;
|
||||
} else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) {
|
||||
// Matched with FE0F/skin tone modifiers treated as optional.
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (candidateScore > bestScore) {
|
||||
matched = &emoteSet[i];
|
||||
matchLen = candidateLen;
|
||||
bestScore = candidateScore;
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
}
|
||||
|
||||
static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight,
|
||||
const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
||||
{
|
||||
// Scan once to collect width and tallest emote for this line.
|
||||
LineMetrics metrics{0, fallbackHeight, false};
|
||||
if (!line)
|
||||
return metrics;
|
||||
|
||||
for (size_t i = 0; i < lineLen;) {
|
||||
size_t matchLen = 0;
|
||||
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
||||
if (matched) {
|
||||
metrics.hasEmote = true;
|
||||
metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height);
|
||||
if (display)
|
||||
metrics.width += matched->width + emoteSpacing;
|
||||
i += matchLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
||||
if (skipLen) {
|
||||
i += skipLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
|
||||
if (display)
|
||||
metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
|
||||
i += charLen;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount,
|
||||
int emoteSpacing)
|
||||
{
|
||||
return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing);
|
||||
}
|
||||
|
||||
int maxEmoteHeight(const Emote *emoteSet, int emoteCount)
|
||||
{
|
||||
int tallest = 0;
|
||||
for (int i = 0; i < emoteCount; ++i) {
|
||||
if (emoteSet[i].label && *emoteSet[i].label)
|
||||
tallest = std::max(tallest, emoteSet[i].height);
|
||||
}
|
||||
return tallest;
|
||||
}
|
||||
|
||||
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
||||
{
|
||||
if (!display)
|
||||
return 0;
|
||||
|
||||
if (!line || !*line)
|
||||
return 0;
|
||||
|
||||
return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width;
|
||||
}
|
||||
|
||||
static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw,
|
||||
bool fauxBold)
|
||||
{
|
||||
// Draw plain-text runs in chunks so UTF-8 stays intact.
|
||||
if (!text || len == 0)
|
||||
return cursorX;
|
||||
|
||||
char chunk[33];
|
||||
size_t pos = 0;
|
||||
while (pos < len) {
|
||||
size_t chunkLen = 0;
|
||||
while (pos + chunkLen < len) {
|
||||
const size_t charLen = utf8CharLen(static_cast<uint8_t>(text[pos + chunkLen]));
|
||||
if (chunkLen + charLen >= sizeof(chunk))
|
||||
break;
|
||||
chunkLen += charLen;
|
||||
}
|
||||
|
||||
if (chunkLen == 0) {
|
||||
chunkLen = std::min(len - pos, sizeof(chunk) - 1);
|
||||
}
|
||||
|
||||
memcpy(chunk, text + pos, chunkLen);
|
||||
chunk[chunkLen] = '\0';
|
||||
if (draw) {
|
||||
if (fauxBold)
|
||||
display->drawString(cursorX + 1, fontY, chunk);
|
||||
display->drawString(cursorX, fontY, chunk);
|
||||
}
|
||||
cursorX += getStringWidth(display, chunk, chunkLen);
|
||||
pos += chunkLen;
|
||||
}
|
||||
|
||||
return cursorX;
|
||||
}
|
||||
|
||||
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis,
|
||||
const Emote *emoteSet, int emoteCount, int emoteSpacing)
|
||||
{
|
||||
if (!out || outSize == 0)
|
||||
return 0;
|
||||
|
||||
out[0] = '\0';
|
||||
if (!display || !line || maxWidth <= 0)
|
||||
return 0;
|
||||
|
||||
const size_t lineLen = strlen(line);
|
||||
const int suffixWidth =
|
||||
(ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0;
|
||||
const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : "";
|
||||
const size_t suffixLen = strlen(suffix);
|
||||
const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0);
|
||||
|
||||
if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) {
|
||||
strncpy(out, line, outSize - 1);
|
||||
out[outSize - 1] = '\0';
|
||||
return strlen(out);
|
||||
}
|
||||
|
||||
int used = 0;
|
||||
size_t cut = 0;
|
||||
for (size_t i = 0; i < lineLen;) {
|
||||
// Keep whole emotes together when deciding where to cut.
|
||||
int tokenWidth = 0;
|
||||
size_t advance = 0;
|
||||
|
||||
if (isPossibleEmoteLead(static_cast<uint8_t>(line[i]))) {
|
||||
size_t matchLen = 0;
|
||||
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
||||
if (matched) {
|
||||
tokenWidth = matched->width + emoteSpacing;
|
||||
advance = matchLen;
|
||||
}
|
||||
}
|
||||
|
||||
if (advance == 0) {
|
||||
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
||||
if (skipLen) {
|
||||
i += skipLen;
|
||||
cut = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
|
||||
tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
|
||||
advance = charLen;
|
||||
}
|
||||
|
||||
if (used + tokenWidth > availableWidth)
|
||||
break;
|
||||
|
||||
used += tokenWidth;
|
||||
i += advance;
|
||||
cut = i;
|
||||
}
|
||||
|
||||
if (cut == 0) {
|
||||
strncpy(out, suffix, outSize - 1);
|
||||
out[outSize - 1] = '\0';
|
||||
return strlen(out);
|
||||
}
|
||||
|
||||
size_t copyLen = cut;
|
||||
if (copyLen > outSize - 1)
|
||||
copyLen = outSize - 1;
|
||||
if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) {
|
||||
copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0;
|
||||
}
|
||||
|
||||
memcpy(out, line, copyLen);
|
||||
size_t totalLen = copyLen;
|
||||
if (suffixLen > 0 && totalLen < outSize - 1) {
|
||||
memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen));
|
||||
totalLen += std::min(suffixLen, outSize - 1 - totalLen);
|
||||
}
|
||||
out[totalLen] = '\0';
|
||||
return totalLen;
|
||||
}
|
||||
|
||||
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet,
|
||||
int emoteCount, int emoteSpacing, bool fauxBold)
|
||||
{
|
||||
if (!line)
|
||||
return;
|
||||
|
||||
const size_t lineLen = strlen(line);
|
||||
// Center text vertically when any emote is taller than the font.
|
||||
const int maxIconHeight =
|
||||
analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight;
|
||||
const int lineHeight = std::max(fontHeight, maxIconHeight);
|
||||
const int fontY = y + (lineHeight - fontHeight) / 2;
|
||||
|
||||
int cursorX = x;
|
||||
bool inBold = false;
|
||||
|
||||
for (size_t i = 0; i < lineLen;) {
|
||||
// Toggle faux bold.
|
||||
if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') {
|
||||
inBold = !inBold;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
|
||||
if (skipLen) {
|
||||
i += skipLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t matchLen = 0;
|
||||
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
|
||||
if (matched) {
|
||||
const int iconY = y + (lineHeight - matched->height) / 2;
|
||||
display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap);
|
||||
cursorX += matched->width + emoteSpacing;
|
||||
i += matchLen;
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t next = i;
|
||||
while (next < lineLen) {
|
||||
// Stop the text run before the next emote or bold marker.
|
||||
if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*')
|
||||
break;
|
||||
|
||||
if (ignorableModifierLenAt(line, next, lineLen))
|
||||
break;
|
||||
|
||||
size_t nextMatchLen = 0;
|
||||
if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
|
||||
break;
|
||||
|
||||
next += utf8CharLen(static_cast<uint8_t>(line[next]));
|
||||
}
|
||||
|
||||
if (next == i)
|
||||
next += utf8CharLen(static_cast<uint8_t>(line[i]));
|
||||
|
||||
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
|
||||
i = next;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace EmoteRenderer
|
||||
} // namespace graphics
|
||||
|
||||
#endif // HAS_SCREEN
|
||||
79
src/graphics/EmoteRenderer.h
Normal file
79
src/graphics/EmoteRenderer.h
Normal file
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_SCREEN
|
||||
#include "graphics/emotes.h"
|
||||
#include <Arduino.h>
|
||||
#include <OLEDDisplay.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
namespace EmoteRenderer
|
||||
{
|
||||
|
||||
struct LineMetrics {
|
||||
int width;
|
||||
int tallestHeight;
|
||||
bool hasEmote;
|
||||
};
|
||||
|
||||
size_t utf8CharLen(uint8_t c);
|
||||
|
||||
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes);
|
||||
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes);
|
||||
inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes)
|
||||
{
|
||||
return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount);
|
||||
}
|
||||
|
||||
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes, int emoteSpacing = 1);
|
||||
inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0,
|
||||
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1)
|
||||
{
|
||||
return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing);
|
||||
}
|
||||
int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes);
|
||||
|
||||
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes,
|
||||
int emoteSpacing = 1);
|
||||
inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes, int emoteSpacing = 1)
|
||||
{
|
||||
return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing);
|
||||
}
|
||||
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
|
||||
const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes,
|
||||
int emoteSpacing = 1);
|
||||
inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth,
|
||||
const std::string &ellipsis = "...", const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes, int emoteSpacing = 1)
|
||||
{
|
||||
if (!display || maxWidth <= 0)
|
||||
return "";
|
||||
if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth)
|
||||
return line;
|
||||
|
||||
std::vector<char> out(line.length() + ellipsis.length() + 1, '\0');
|
||||
truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount,
|
||||
emoteSpacing);
|
||||
return std::string(out.data());
|
||||
}
|
||||
|
||||
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes,
|
||||
int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true);
|
||||
inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
|
||||
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1,
|
||||
bool fauxBold = true)
|
||||
{
|
||||
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold);
|
||||
}
|
||||
|
||||
} // namespace EmoteRenderer
|
||||
} // namespace graphics
|
||||
|
||||
#endif // HAS_SCREEN
|
||||
@@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
|
||||
}
|
||||
|
||||
// === Screen Title ===
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(SCREEN_WIDTH / 2, y, titleStr);
|
||||
if (config.display.heading_bold) {
|
||||
display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr);
|
||||
}
|
||||
const char *headerTitle = titleStr ? titleStr : "";
|
||||
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
|
||||
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
|
||||
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
|
||||
}
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
|
||||
@@ -515,4 +514,4 @@ std::string sanitizeString(const std::string &input)
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "NodeDB.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
@@ -34,44 +35,6 @@ static std::vector<std::string> cachedLines;
|
||||
static std::vector<int> cachedHeights;
|
||||
static bool manualScrolling = false;
|
||||
|
||||
// UTF-8 skip helper
|
||||
static inline size_t utf8CharLen(uint8_t c)
|
||||
{
|
||||
if ((c & 0xE0) == 0xC0)
|
||||
return 2;
|
||||
if ((c & 0xF0) == 0xE0)
|
||||
return 3;
|
||||
if ((c & 0xF8) == 0xF0)
|
||||
return 4;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels
|
||||
static std::string normalizeEmoji(const std::string &s)
|
||||
{
|
||||
std::string out;
|
||||
for (size_t i = 0; i < s.size();) {
|
||||
uint8_t c = static_cast<uint8_t>(s[i]);
|
||||
size_t len = utf8CharLen(c);
|
||||
|
||||
if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) {
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip skin tone modifiers
|
||||
if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F &&
|
||||
((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) {
|
||||
i += 4;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.append(s, i, len);
|
||||
i += len;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Scroll state (file scope so we can reset on new message)
|
||||
float scrollY = 0.0f;
|
||||
uint32_t lastTime = 0;
|
||||
@@ -110,102 +73,7 @@ void scrollDown()
|
||||
|
||||
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
|
||||
{
|
||||
int cursorX = x;
|
||||
const int fontHeight = FONT_HEIGHT_SMALL;
|
||||
|
||||
// Step 1: Find tallest emote in the line
|
||||
int maxIconHeight = fontHeight;
|
||||
for (size_t i = 0; i < line.length();) {
|
||||
bool matched = false;
|
||||
for (int e = 0; e < emoteCount; ++e) {
|
||||
size_t emojiLen = strlen(emotes[e].label);
|
||||
if (line.compare(i, emojiLen, emotes[e].label) == 0) {
|
||||
if (emotes[e].height > maxIconHeight)
|
||||
maxIconHeight = emotes[e].height;
|
||||
i += emojiLen;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
i += utf8CharLen(static_cast<uint8_t>(line[i]));
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Baseline alignment
|
||||
int lineHeight = std::max(fontHeight, maxIconHeight);
|
||||
int baselineOffset = (lineHeight - fontHeight) / 2;
|
||||
int fontY = y + baselineOffset;
|
||||
|
||||
// Step 3: Render line in segments
|
||||
size_t i = 0;
|
||||
bool inBold = false;
|
||||
|
||||
while (i < line.length()) {
|
||||
// Check for ** start/end for faux bold
|
||||
if (line.compare(i, 2, "**") == 0) {
|
||||
inBold = !inBold;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Look ahead for the next emote match
|
||||
size_t nextEmotePos = std::string::npos;
|
||||
const Emote *matchedEmote = nullptr;
|
||||
size_t emojiLen = 0;
|
||||
|
||||
for (int e = 0; e < emoteCount; ++e) {
|
||||
size_t pos = line.find(emotes[e].label, i);
|
||||
if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) {
|
||||
nextEmotePos = pos;
|
||||
matchedEmote = &emotes[e];
|
||||
emojiLen = strlen(emotes[e].label);
|
||||
}
|
||||
}
|
||||
|
||||
// Render normal text segment up to the emote or bold toggle
|
||||
size_t nextControl = std::min(nextEmotePos, line.find("**", i));
|
||||
if (nextControl == std::string::npos)
|
||||
nextControl = line.length();
|
||||
|
||||
if (nextControl > i) {
|
||||
std::string textChunk = line.substr(i, nextControl - i);
|
||||
if (inBold) {
|
||||
// Faux bold: draw twice, offset by 1px
|
||||
display->drawString(cursorX + 1, fontY, textChunk.c_str());
|
||||
}
|
||||
display->drawString(cursorX, fontY, textChunk.c_str());
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true);
|
||||
#else
|
||||
cursorX += display->getStringWidth(textChunk.c_str());
|
||||
#endif
|
||||
i = nextControl;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render the emote (if found)
|
||||
if (matchedEmote && i == nextEmotePos) {
|
||||
int iconY = y + (lineHeight - matchedEmote->height) / 2;
|
||||
display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
|
||||
cursorX += matchedEmote->width + 1;
|
||||
i += emojiLen;
|
||||
continue;
|
||||
} else {
|
||||
// No more emotes — render the rest of the line
|
||||
std::string remaining = line.substr(i);
|
||||
if (inBold) {
|
||||
display->drawString(cursorX + 1, fontY, remaining.c_str());
|
||||
}
|
||||
display->drawString(cursorX, fontY, remaining.c_str());
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true);
|
||||
#else
|
||||
cursorX += display->getStringWidth(remaining.c_str());
|
||||
#endif
|
||||
break;
|
||||
}
|
||||
}
|
||||
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
|
||||
}
|
||||
|
||||
// Reset scroll state when new messages arrive
|
||||
@@ -377,32 +245,7 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
|
||||
|
||||
static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
|
||||
{
|
||||
std::string normalized = normalizeEmoji(line);
|
||||
int totalWidth = 0;
|
||||
|
||||
size_t i = 0;
|
||||
while (i < normalized.length()) {
|
||||
bool matched = false;
|
||||
for (int e = 0; e < emoteCount; ++e) {
|
||||
size_t emojiLen = strlen(emotes[e].label);
|
||||
if (normalized.compare(i, emojiLen, emotes[e].label) == 0) {
|
||||
totalWidth += emotes[e].width + 1; // +1 spacing
|
||||
i += emojiLen;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
size_t charLen = utf8CharLen(static_cast<uint8_t>(normalized[i]));
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true);
|
||||
#else
|
||||
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str());
|
||||
#endif
|
||||
i += charLen;
|
||||
}
|
||||
}
|
||||
return totalWidth;
|
||||
return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
|
||||
}
|
||||
|
||||
struct MessageBlock {
|
||||
@@ -417,13 +260,7 @@ static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool i
|
||||
return lineTopY + (FONT_HEIGHT_SMALL - 1);
|
||||
}
|
||||
|
||||
int tallest = FONT_HEIGHT_SMALL;
|
||||
for (int e = 0; e < numEmotes; ++e) {
|
||||
if (line.find(emotes[e].label) != std::string::npos) {
|
||||
if (emotes[e].height > tallest)
|
||||
tallest = emotes[e].height;
|
||||
}
|
||||
}
|
||||
const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight;
|
||||
|
||||
const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest);
|
||||
const int iconTop = lineTopY + (lineHeight - tallest) / 2;
|
||||
@@ -536,30 +373,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;
|
||||
|
||||
// Title string depending on mode
|
||||
static char titleBuf[32];
|
||||
const char *titleStr = "Messages";
|
||||
char titleStr[48];
|
||||
snprintf(titleStr, sizeof(titleStr), "Messages");
|
||||
switch (currentMode) {
|
||||
case ThreadMode::ALL:
|
||||
titleStr = "Messages";
|
||||
snprintf(titleStr, sizeof(titleStr), "Messages");
|
||||
break;
|
||||
case ThreadMode::CHANNEL: {
|
||||
const char *cname = channels.getName(currentChannel);
|
||||
if (cname && cname[0]) {
|
||||
snprintf(titleBuf, sizeof(titleBuf), "#%s", cname);
|
||||
snprintf(titleStr, sizeof(titleStr), "#%s", cname);
|
||||
} else {
|
||||
snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel);
|
||||
snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
|
||||
}
|
||||
titleStr = titleBuf;
|
||||
break;
|
||||
}
|
||||
case ThreadMode::DIRECT: {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
|
||||
if (node && node->has_user) {
|
||||
snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name);
|
||||
if (node && node->has_user && node->user.short_name[0]) {
|
||||
snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
|
||||
} else {
|
||||
snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer);
|
||||
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
|
||||
}
|
||||
titleStr = titleBuf;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -666,44 +501,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
|
||||
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
|
||||
|
||||
char senderBuf[48] = "";
|
||||
char senderName[64] = "";
|
||||
if (node && node->has_user) {
|
||||
// Use long name if present
|
||||
strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1);
|
||||
senderBuf[sizeof(senderBuf) - 1] = '\0';
|
||||
} else {
|
||||
// No long/short name → show NodeID in parentheses
|
||||
snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender);
|
||||
if (node->user.long_name[0]) {
|
||||
strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
|
||||
} else if (node->user.short_name[0]) {
|
||||
strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
|
||||
}
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
}
|
||||
if (!senderName[0]) {
|
||||
snprintf(senderName, sizeof(senderName), "(%08x)", m.sender);
|
||||
}
|
||||
|
||||
// If this is *our own* message, override senderBuf to who the recipient was
|
||||
// If this is *our own* message, override senderName to who the recipient was
|
||||
bool mine = (m.sender == nodeDB->getNodeNum());
|
||||
if (mine && node_recipient && node_recipient->has_user) {
|
||||
strcpy(senderBuf, node_recipient->user.long_name);
|
||||
if (node_recipient->user.long_name[0]) {
|
||||
strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
} else if (node_recipient->user.short_name[0]) {
|
||||
strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
|
||||
senderName[sizeof(senderName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
|
||||
if (mine && (!node_recipient || !node_recipient->has_user ||
|
||||
(!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
|
||||
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
|
||||
}
|
||||
|
||||
// Shrink Sender name if needed
|
||||
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
|
||||
display->getStringWidth(chanType) - display->getStringWidth(" @...");
|
||||
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @...");
|
||||
if (availWidth < 0)
|
||||
availWidth = 0;
|
||||
|
||||
size_t origLen = strlen(senderBuf);
|
||||
while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) {
|
||||
senderBuf[strlen(senderBuf) - 1] = '\0';
|
||||
}
|
||||
|
||||
// If we actually truncated, append "..."
|
||||
if (strlen(senderBuf) < origLen) {
|
||||
strcat(senderBuf, "...");
|
||||
}
|
||||
char truncatedSender[64];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
|
||||
|
||||
// Final header line
|
||||
char headerStr[96];
|
||||
char headerStr[128];
|
||||
if (mine) {
|
||||
if (currentMode == ThreadMode::ALL) {
|
||||
if (strcmp(chanType, "(DM)") == 0) {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf);
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
|
||||
}
|
||||
@@ -711,11 +552,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
|
||||
}
|
||||
} else {
|
||||
snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType);
|
||||
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType);
|
||||
}
|
||||
|
||||
// Push header line
|
||||
allLines.push_back(std::string(headerStr));
|
||||
allLines.push_back(headerStr);
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(true);
|
||||
ackForLine.push_back(m.ackStatus);
|
||||
@@ -816,13 +657,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
topY = visualTop - BUBBLE_PAD_TOP_HEADER;
|
||||
} else {
|
||||
// Body start
|
||||
bool thisLineHasEmote = false;
|
||||
for (int e = 0; e < numEmotes; ++e) {
|
||||
if (cachedLines[b.start].find(emotes[e].label) != std::string::npos) {
|
||||
thisLineHasEmote = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const bool thisLineHasEmote =
|
||||
graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote;
|
||||
if (thisLineHasEmote) {
|
||||
constexpr int EMOTE_PADDING_ABOVE = 4;
|
||||
visualTop -= EMOTE_PADDING_ABOVE;
|
||||
@@ -851,7 +687,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
for (size_t i = b.start; i <= b.end; ++i) {
|
||||
int w = 0;
|
||||
if (isHeader[i]) {
|
||||
w = display->getStringWidth(cachedLines[i].c_str());
|
||||
w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
|
||||
if (b.mine)
|
||||
w += 12; // room for ACK/NACK/relay mark
|
||||
} else {
|
||||
@@ -907,7 +743,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
|
||||
if (isHeader[i]) {
|
||||
|
||||
int w = display->getStringWidth(cachedLines[i].c_str());
|
||||
int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
|
||||
int headerX;
|
||||
if (isMine[i]) {
|
||||
// push header left to avoid overlap with scrollbar
|
||||
@@ -917,7 +753,8 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
} else {
|
||||
headerX = x + textIndent;
|
||||
}
|
||||
display->drawString(headerX, lineY, cachedLines[i].c_str());
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
|
||||
// Draw underline just under header text
|
||||
int underlineY = lineY + FONT_HEIGHT_SMALL;
|
||||
@@ -1005,11 +842,7 @@ std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerS
|
||||
} else {
|
||||
word += ch;
|
||||
std::string test = line + word;
|
||||
#if defined(OLED_UA) || defined(OLED_RU)
|
||||
uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
|
||||
#else
|
||||
uint16_t strWidth = display->getStringWidth(test.c_str());
|
||||
#endif
|
||||
uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
|
||||
if (strWidth > textWidth) {
|
||||
if (!line.empty())
|
||||
lines.push_back(line);
|
||||
@@ -1038,31 +871,20 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
|
||||
std::vector<int> rowHeights;
|
||||
rowHeights.reserve(lines.size());
|
||||
std::vector<graphics::EmoteRenderer::LineMetrics> lineMetrics;
|
||||
lineMetrics.reserve(lines.size());
|
||||
|
||||
for (const auto &line : lines) {
|
||||
lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes));
|
||||
}
|
||||
|
||||
for (size_t idx = 0; idx < lines.size(); ++idx) {
|
||||
const auto &line = lines[idx];
|
||||
const int baseHeight = FONT_HEIGHT_SMALL;
|
||||
int lineHeight = baseHeight;
|
||||
|
||||
// Detect if THIS line or NEXT line contains an emote
|
||||
bool hasEmote = false;
|
||||
int tallestEmote = baseHeight;
|
||||
for (int i = 0; i < numEmotes; ++i) {
|
||||
if (line.find(emotes[i].label) != std::string::npos) {
|
||||
hasEmote = true;
|
||||
tallestEmote = std::max(tallestEmote, emotes[i].height);
|
||||
}
|
||||
}
|
||||
|
||||
bool nextHasEmote = false;
|
||||
if (idx + 1 < lines.size()) {
|
||||
for (int i = 0; i < numEmotes; ++i) {
|
||||
if (lines[idx + 1].find(emotes[i].label) != std::string::npos) {
|
||||
nextHasEmote = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const int tallestEmote = lineMetrics[idx].tallestHeight;
|
||||
const bool hasEmote = lineMetrics[idx].hasEmote;
|
||||
const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote;
|
||||
|
||||
if (isHeaderVec[idx]) {
|
||||
// Header line spacing
|
||||
@@ -1112,22 +934,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
|
||||
// Banner logic
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
|
||||
char longName[48] = "?";
|
||||
if (node && node->user.long_name) {
|
||||
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
char longName[64] = "?";
|
||||
if (node && node->has_user) {
|
||||
if (node->user.long_name[0]) {
|
||||
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
} else if (node->user.short_name[0]) {
|
||||
strncpy(longName, node->user.short_name, sizeof(longName) - 1);
|
||||
longName[sizeof(longName) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
|
||||
if (availWidth < 0)
|
||||
availWidth = 0;
|
||||
|
||||
size_t origLen = strlen(longName);
|
||||
while (longName[0] && display->getStringWidth(longName) > availWidth) {
|
||||
longName[strlen(longName) - 1] = '\0';
|
||||
}
|
||||
if (strlen(longName) < origLen) {
|
||||
strcat(longName, "...");
|
||||
}
|
||||
char truncatedLongName[64];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
|
||||
availWidth);
|
||||
const char *msgRaw = reinterpret_cast<const char *>(packet.decoded.payload.bytes);
|
||||
|
||||
char banner[256];
|
||||
@@ -1145,8 +967,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
}
|
||||
|
||||
if (isAlert) {
|
||||
if (longName && longName[0])
|
||||
snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
|
||||
if (truncatedLongName[0])
|
||||
snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
|
||||
else
|
||||
strcpy(banner, "Alert Received");
|
||||
} else {
|
||||
@@ -1154,11 +976,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
|
||||
if (isChannelMuted)
|
||||
return;
|
||||
|
||||
if (longName && longName[0]) {
|
||||
if (truncatedLongName[0]) {
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
strcpy(banner, "New Message");
|
||||
} else {
|
||||
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
|
||||
snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
|
||||
}
|
||||
} else
|
||||
strcpy(banner, "New Message");
|
||||
@@ -1221,4 +1043,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
|
||||
|
||||
} // namespace MessageRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -79,13 +79,15 @@ void scrollDown()
|
||||
// Utility Functions
|
||||
// =============================
|
||||
|
||||
const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
|
||||
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
|
||||
{
|
||||
static char nodeName[25]; // single static buffer we return
|
||||
nodeName[0] = '\0';
|
||||
(void)display;
|
||||
(void)columnWidth;
|
||||
|
||||
auto writeFallbackId = [&] {
|
||||
std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
|
||||
auto fallbackId = [&] {
|
||||
char id[12];
|
||||
std::snprintf(id, sizeof(id), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
|
||||
return std::string(id);
|
||||
};
|
||||
|
||||
// 1) Choose target candidate (long vs short) only if present
|
||||
@@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
|
||||
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
|
||||
}
|
||||
|
||||
// 2) Sanitize (empty if raw is null/empty)
|
||||
std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{};
|
||||
|
||||
// 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed)
|
||||
if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) {
|
||||
writeFallbackId();
|
||||
} else {
|
||||
// %.*s ensures null-termination and safe truncation to buffer size - 1
|
||||
std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast<int>(sizeof(nodeName) - 1), s.c_str());
|
||||
}
|
||||
|
||||
// 4) Width-based truncation + ellipsis (long-name mode only)
|
||||
if (config.display.use_long_node_name && display) {
|
||||
int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
|
||||
if (availWidth < 0)
|
||||
availWidth = 0;
|
||||
|
||||
const size_t beforeLen = std::strlen(nodeName);
|
||||
|
||||
// Trim from the end until it fits or is empty
|
||||
size_t len = beforeLen;
|
||||
while (len && display->getStringWidth(nodeName) > availWidth) {
|
||||
nodeName[--len] = '\0';
|
||||
}
|
||||
|
||||
// If truncated, append "..." (respect buffer size)
|
||||
if (len < beforeLen) {
|
||||
// Make sure there's room for "..." and '\0'
|
||||
const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0'
|
||||
const size_t needed = 3; // "..."
|
||||
if (len > capForText - needed) {
|
||||
len = capForText - needed;
|
||||
nodeName[len] = '\0';
|
||||
}
|
||||
std::strcat(nodeName, "...");
|
||||
}
|
||||
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
|
||||
std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{};
|
||||
if (nodeName.empty()) {
|
||||
nodeName = fallbackId();
|
||||
}
|
||||
|
||||
return nodeName;
|
||||
@@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth)
|
||||
}
|
||||
}
|
||||
|
||||
static int getNodeNameMaxWidth(int columnWidth, int baseWidth)
|
||||
{
|
||||
if (!config.display.use_long_node_name)
|
||||
return baseWidth;
|
||||
|
||||
const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
|
||||
return std::max(0, std::min(baseWidth, legacyLongNameWidth));
|
||||
}
|
||||
|
||||
// Use dynamic timing based on mode
|
||||
unsigned long getModeCycleIntervalMs()
|
||||
{
|
||||
@@ -205,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
|
||||
void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
|
||||
{
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
int nameMaxWidth = columnWidth - 25;
|
||||
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
|
||||
int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
|
||||
|
||||
const char *nodeName = getSafeNodeName(display, node, columnWidth);
|
||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
|
||||
char timeStr[10];
|
||||
@@ -228,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
@@ -255,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
|
||||
{
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
|
||||
int nameMaxWidth = columnWidth - 25;
|
||||
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
|
||||
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
|
||||
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
|
||||
|
||||
int barsXOffset = columnWidth - barsOffset;
|
||||
|
||||
const char *nodeName = getSafeNodeName(display, node, columnWidth);
|
||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
|
||||
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
@@ -312,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
{
|
||||
bool isLeftCol = (x < SCREEN_WIDTH / 2);
|
||||
int nameMaxWidth =
|
||||
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
|
||||
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
|
||||
: (isLeftCol ? 20 : 22)));
|
||||
|
||||
const char *nodeName = getSafeNodeName(display, node, columnWidth);
|
||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
char distStr[10] = "";
|
||||
|
||||
@@ -368,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
@@ -414,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
|
||||
|
||||
// Adjust max text width depending on column and screen width
|
||||
int nameMaxWidth =
|
||||
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
|
||||
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
|
||||
: (isLeftCol ? 20 : 22)));
|
||||
|
||||
const char *nodeName = getSafeNodeName(display, node, columnWidth);
|
||||
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
|
||||
char nodeName[96];
|
||||
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
|
||||
nameMaxWidth);
|
||||
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
display->setFont(FONT_SMALL);
|
||||
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
|
||||
if (node->is_favorite) {
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
|
||||
@@ -828,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields
|
||||
|
||||
} // namespace NodeListRenderer
|
||||
} // namespace graphics
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <OLEDDisplay.h>
|
||||
#include <OLEDDisplayUi.h>
|
||||
#include <string>
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
@@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
|
||||
// Utility functions
|
||||
const char *getCurrentModeTitle_Nodes(int screenWidth);
|
||||
const char *getCurrentModeTitle_Location(int screenWidth);
|
||||
const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth);
|
||||
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
|
||||
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
|
||||
|
||||
// Scrolling controls
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "DisplayFormatters.h"
|
||||
#include "NodeDB.h"
|
||||
#include "NotificationRenderer.h"
|
||||
#include "UIRenderer.h"
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/images.h"
|
||||
@@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
|
||||
for (int i = 0; i < lineCount; i++) {
|
||||
linePointers[i] = lineStarts[i];
|
||||
}
|
||||
char scratchLineBuffer[visibleTotalLines - lineCount][40];
|
||||
char scratchLineBuffer[visibleTotalLines - lineCount][64];
|
||||
|
||||
uint8_t firstOptionToShow = 0;
|
||||
if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) {
|
||||
@@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
|
||||
}
|
||||
int scratchLineNum = 0;
|
||||
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
|
||||
char temp_name[16] = {0};
|
||||
if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) {
|
||||
std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name);
|
||||
strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1);
|
||||
char tempName[48] = {0};
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
|
||||
if (node && node->has_user) {
|
||||
const char *rawName = nullptr;
|
||||
if (node->user.long_name[0]) {
|
||||
rawName = node->user.long_name;
|
||||
} else if (node->user.short_name[0]) {
|
||||
rawName = node->user.short_name;
|
||||
}
|
||||
if (rawName) {
|
||||
const int arrowWidth = (currentResolution == ScreenResolution::High)
|
||||
? UIRenderer::measureStringWithEmotes(display, "> <")
|
||||
: UIRenderer::measureStringWithEmotes(display, "><");
|
||||
const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
|
||||
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
|
||||
}
|
||||
} else {
|
||||
snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF));
|
||||
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
|
||||
}
|
||||
if (!tempName[0]) {
|
||||
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
|
||||
}
|
||||
if (i == curSelected) {
|
||||
selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num;
|
||||
selectedNodenum = node ? node->num : 0;
|
||||
if (currentResolution == ScreenResolution::High) {
|
||||
strncpy(scratchLineBuffer[scratchLineNum], "> ", 3);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3);
|
||||
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
|
||||
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
|
||||
} else {
|
||||
strncpy(scratchLineBuffer[scratchLineNum], ">", 2);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2);
|
||||
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
|
||||
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
|
||||
strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
|
||||
}
|
||||
scratchLineBuffer[scratchLineNum][39] = '\0';
|
||||
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
|
||||
} else {
|
||||
strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39);
|
||||
scratchLineBuffer[scratchLineNum][39] = '\0';
|
||||
strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1);
|
||||
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
|
||||
}
|
||||
linePointers[linesShown] = scratchLineBuffer[scratchLineNum++];
|
||||
}
|
||||
@@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
else // if the newline wasn't found, then pull string length from strlen
|
||||
lineLengths[lineCount] = strlen(lines[lineCount]);
|
||||
|
||||
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
char measureBuffer[64] = {0};
|
||||
strncpy(measureBuffer, lines[lineCount], std::min<size_t>(lineLengths[lineCount], sizeof(measureBuffer) - 1));
|
||||
lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer);
|
||||
} else {
|
||||
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
|
||||
}
|
||||
|
||||
// Consider extra width for signal bars on lines that contain "Signal:"
|
||||
uint16_t potentialWidth = lineWidths[lineCount];
|
||||
@@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
|
||||
display->setColor(BLACK);
|
||||
int yOffset = 3;
|
||||
display->drawString(textX, lineY - yOffset, lineBuffer);
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(textX, lineY - yOffset, lineBuffer);
|
||||
}
|
||||
display->setColor(WHITE);
|
||||
lineY += (effectiveLineHeight - 2 - background_yOffset);
|
||||
} else {
|
||||
@@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
int totalWidth = textWidth + barsWidth;
|
||||
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
|
||||
|
||||
display->drawString(groupStartX, lineY, lineBuffer);
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(groupStartX, lineY, lineBuffer);
|
||||
}
|
||||
|
||||
int baseX = groupStartX + textWidth + gap;
|
||||
int baseY = lineY + effectiveLineHeight - 1;
|
||||
@@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
|
||||
}
|
||||
}
|
||||
} else {
|
||||
display->drawString(textX, lineY, lineBuffer);
|
||||
if (current_notification_type == notificationTypeEnum::node_picker) {
|
||||
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
display->drawString(textX, lineY, lineBuffer);
|
||||
}
|
||||
}
|
||||
lineY += (effectiveLineHeight);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "UIRenderer.h"
|
||||
#include "airtime.h"
|
||||
#include "gps/GeoCoord.h"
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/TimeFormatters.h"
|
||||
#include "graphics/images.h"
|
||||
@@ -313,8 +314,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
|
||||
#endif
|
||||
currentFavoriteNodeNum = node->num;
|
||||
// === Create the shortName and title string ===
|
||||
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
|
||||
char titlestr[32] = {0};
|
||||
const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
|
||||
char titlestr[40];
|
||||
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
|
||||
|
||||
// === Draw battery/time/mail header (common across screens) ===
|
||||
@@ -328,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
|
||||
|
||||
// List of available macro Y positions in order, from top to bottom.
|
||||
int line = 1; // which slot to use next
|
||||
std::string usernameStr;
|
||||
// === 1. Long Name (always try to show first) ===
|
||||
const char *username;
|
||||
if (currentResolution == ScreenResolution::UltraLow) {
|
||||
@@ -338,9 +338,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, i
|
||||
}
|
||||
|
||||
if (username) {
|
||||
usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case
|
||||
// Print node's long name (e.g. "Backpack Node")
|
||||
display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
|
||||
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
|
||||
}
|
||||
|
||||
// === 2. Signal and Hops (combined on one line, if available) ===
|
||||
@@ -802,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
// === Node Identity ===
|
||||
int textWidth = 0;
|
||||
int nameX = 0;
|
||||
char shortnameble[35];
|
||||
snprintf(shortnameble, sizeof(shortnameble), "%s",
|
||||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||||
const char *shortName = owner.short_name ? owner.short_name : "";
|
||||
|
||||
// === ShortName Centered ===
|
||||
textWidth = display->getStringWidth(shortnameble);
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
|
||||
#else
|
||||
if (powerStatus->getHasBattery()) {
|
||||
char batStr[20];
|
||||
@@ -904,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
|
||||
int textWidth = 0;
|
||||
int nameX = 0;
|
||||
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
|
||||
std::string longNameStr;
|
||||
|
||||
if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
|
||||
longNameStr = sanitizeString(ourNode->user.long_name);
|
||||
const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
|
||||
const char *shortName = owner.short_name ? owner.short_name : "";
|
||||
char combinedName[96];
|
||||
if (longName[0] && shortName[0]) {
|
||||
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName);
|
||||
} else if (longName[0]) {
|
||||
strncpy(combinedName, longName, sizeof(combinedName) - 1);
|
||||
combinedName[sizeof(combinedName) - 1] = '\0';
|
||||
} else {
|
||||
strncpy(combinedName, shortName, sizeof(combinedName) - 1);
|
||||
combinedName[sizeof(combinedName) - 1] = '\0';
|
||||
}
|
||||
char shortnameble[35];
|
||||
snprintf(shortnameble, sizeof(shortnameble), "%s",
|
||||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||||
|
||||
char combinedName[50];
|
||||
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble);
|
||||
if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) {
|
||||
size_t len = strlen(combinedName);
|
||||
if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
|
||||
combinedName[len - 3] = '\0'; // Remove the last three characters
|
||||
}
|
||||
textWidth = display->getStringWidth(combinedName);
|
||||
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(
|
||||
nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
|
||||
UIRenderer::drawStringWithEmotes(
|
||||
display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset,
|
||||
combinedName, FONT_HEIGHT_SMALL, 1, false);
|
||||
} else {
|
||||
// === LongName Centered ===
|
||||
textWidth = display->getStringWidth(longNameStr.c_str());
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str());
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
|
||||
// === ShortName Centered ===
|
||||
textWidth = display->getStringWidth(shortnameble);
|
||||
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
||||
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
|
||||
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
}
|
||||
#endif
|
||||
graphics::drawCommonFooter(display, x, y);
|
||||
@@ -1045,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
const char *pauseText = "Screen Paused";
|
||||
const char *idText = owner.short_name;
|
||||
const bool useId = haveGlyphs(idText);
|
||||
const bool useId = (idText && idText[0]);
|
||||
constexpr uint8_t padding = 2;
|
||||
constexpr uint8_t dividerGap = 1;
|
||||
|
||||
// Text widths
|
||||
const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true);
|
||||
const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0;
|
||||
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
|
||||
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
|
||||
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
|
||||
@@ -1075,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
|
||||
|
||||
// Draw: text
|
||||
if (useId)
|
||||
display->drawString(idTextLeft, idTextTop, idText);
|
||||
UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false);
|
||||
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
|
||||
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
|
||||
|
||||
@@ -1108,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
|
||||
display->drawString(msgX, msgY, upperMsg);
|
||||
}
|
||||
// Draw version and short name in bottom middle
|
||||
char buf[25];
|
||||
snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT),
|
||||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||||
|
||||
display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf);
|
||||
char footer[64];
|
||||
if (owner.short_name && owner.short_name[0]) {
|
||||
snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name);
|
||||
} else {
|
||||
snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT));
|
||||
}
|
||||
int footerW = UIRenderer::measureStringWithEmotes(display, footer);
|
||||
int footerX = x + ((SCREEN_WIDTH - footerW) / 2);
|
||||
UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1,
|
||||
false);
|
||||
screen->forceDisplay();
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||||
@@ -1130,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
|
||||
display->drawString(x + 0, y + 0, upperMsg);
|
||||
|
||||
// Draw version and short name in upper right
|
||||
char buf[25];
|
||||
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT),
|
||||
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
|
||||
const char *version = xstr(APP_VERSION_SHORT);
|
||||
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
|
||||
display->drawString(versionX, y + 0, version);
|
||||
if (owner.short_name && owner.short_name[0]) {
|
||||
const char *shortName = owner.short_name;
|
||||
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
int shortNameX = x + SCREEN_WIDTH - shortNameW;
|
||||
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
|
||||
}
|
||||
screen->forceDisplay();
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||||
@@ -1365,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O
|
||||
display->drawString(x + 0, y + 0, upperMsg);
|
||||
|
||||
// Draw version and shortname in upper right
|
||||
char buf[25];
|
||||
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : "");
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_RIGHT);
|
||||
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
|
||||
const char *version = xstr(APP_VERSION_SHORT);
|
||||
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
|
||||
display->drawString(versionX, y + 0, version);
|
||||
if (owner.short_name && owner.short_name[0]) {
|
||||
const char *shortName = owner.short_name;
|
||||
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
|
||||
int shortNameX = x + SCREEN_WIDTH - shortNameW;
|
||||
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
|
||||
}
|
||||
screen->forceDisplay();
|
||||
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
||||
@@ -1558,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi
|
||||
return uptime;
|
||||
}
|
||||
|
||||
int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing)
|
||||
{
|
||||
return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing);
|
||||
}
|
||||
|
||||
size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
|
||||
const char *ellipsis, int emoteSpacing)
|
||||
{
|
||||
return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes,
|
||||
graphics::numEmotes, emoteSpacing);
|
||||
}
|
||||
|
||||
void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing,
|
||||
bool fauxBold)
|
||||
{
|
||||
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes,
|
||||
emoteSpacing, fauxBold);
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
#endif // HAS_SCREEN
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "NodeDB.h"
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include <OLEDDisplay.h>
|
||||
@@ -80,6 +81,28 @@ class UIRenderer
|
||||
static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
|
||||
static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
|
||||
|
||||
// Shared BaseUI emote helpers.
|
||||
static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1);
|
||||
static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1)
|
||||
{
|
||||
return measureStringWithEmotes(display, line.c_str(), emoteSpacing);
|
||||
}
|
||||
static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
|
||||
const char *ellipsis = "...", int emoteSpacing = 1);
|
||||
static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth,
|
||||
const std::string &ellipsis = "...", int emoteSpacing = 1)
|
||||
{
|
||||
return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes,
|
||||
emoteSpacing);
|
||||
}
|
||||
static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1,
|
||||
bool fauxBold = true);
|
||||
static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
|
||||
int emoteSpacing = 1, bool fauxBold = true)
|
||||
{
|
||||
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
|
||||
}
|
||||
|
||||
// Check if the display can render a string (detect special chars; emoji)
|
||||
static bool haveGlyphs(const char *str);
|
||||
}; // namespace UIRenderer
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
#include "buzz.h"
|
||||
#include "detect/ScanI2C.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "graphics/EmoteRenderer.h"
|
||||
#include "graphics/Screen.h"
|
||||
#include "graphics/SharedUIDisplay.h"
|
||||
#include "graphics/draw/MessageRenderer.h"
|
||||
#include "graphics/draw/NotificationRenderer.h"
|
||||
#include "graphics/draw/UIRenderer.h"
|
||||
#include "graphics/emotes.h"
|
||||
#include "graphics/images.h"
|
||||
#include "input/SerialKeyboard.h"
|
||||
@@ -45,71 +47,6 @@ extern MessageStore messageStore;
|
||||
// Remove Canned message screen if no action is taken for some milliseconds
|
||||
#define INACTIVATE_AFTER_MS 20000
|
||||
|
||||
// Tokenize a message string into emote/text segments
|
||||
static std::vector<std::pair<bool, String>> tokenizeMessageWithEmotes(const char *msg)
|
||||
{
|
||||
std::vector<std::pair<bool, String>> tokens;
|
||||
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;
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// Render a single emote token centered vertically on a row
|
||||
static void renderEmote(OLEDDisplay *display, int &nextX, int lineY, int rowHeight, const String &label)
|
||||
{
|
||||
const graphics::Emote *emote = nullptr;
|
||||
for (int j = 0; j < graphics::numEmotes; j++) {
|
||||
if (label == graphics::emotes[j].label) {
|
||||
emote = &graphics::emotes[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (emote) {
|
||||
int emoteYOffset = (rowHeight - emote->height) / 2; // vertically center the emote
|
||||
display->drawXbm(nextX, lineY + emoteYOffset, emote->width, emote->height, emote->bitmap);
|
||||
nextX += emote->width + 2; // spacing between tokens
|
||||
}
|
||||
}
|
||||
|
||||
namespace graphics
|
||||
{
|
||||
extern int bannerSignalBars;
|
||||
@@ -264,19 +201,20 @@ int CannedMessageModule::splitConfiguredMessages()
|
||||
}
|
||||
void CannedMessageModule::drawHeader(OLEDDisplay *display, int16_t x, int16_t y, char *buffer)
|
||||
{
|
||||
if (graphics::currentResolution == graphics::ScreenResolution::High) {
|
||||
if (this->dest == NODENUM_BROADCAST) {
|
||||
display->drawStringf(x, y, buffer, "To: #%s", channels.getName(this->channel));
|
||||
} else {
|
||||
display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
|
||||
}
|
||||
(void)buffer;
|
||||
|
||||
char header[96];
|
||||
if (this->dest == NODENUM_BROADCAST) {
|
||||
const char *channelName = channels.getName(this->channel);
|
||||
snprintf(header, sizeof(header), "To: #%s", channelName ? channelName : "?");
|
||||
} else {
|
||||
if (this->dest == NODENUM_BROADCAST) {
|
||||
display->drawStringf(x, y, buffer, "To: #%.20s", channels.getName(this->channel));
|
||||
} else {
|
||||
display->drawStringf(x, y, buffer, "To: @%s", getNodeName(this->dest));
|
||||
}
|
||||
snprintf(header, sizeof(header), "To: @%s", getNodeName(this->dest));
|
||||
}
|
||||
|
||||
const int maxWidth = std::max(0, display->getWidth() - x);
|
||||
char truncatedHeader[96];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, header, truncatedHeader, sizeof(truncatedHeader), maxWidth);
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, x, y, truncatedHeader, FONT_HEIGHT_SMALL, 1, false);
|
||||
}
|
||||
|
||||
void CannedMessageModule::resetSearch()
|
||||
@@ -370,6 +308,92 @@ bool CannedMessageModule::isCharInputAllowed() const
|
||||
{
|
||||
return runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
|
||||
}
|
||||
|
||||
static int getRowHeightForEmoteText(const char *text, int minimumHeight, int emoteSpacing = 2)
|
||||
{
|
||||
// Grow the row only when an emote is taller than the font.
|
||||
const auto metrics =
|
||||
graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", 0, graphics::emotes, graphics::numEmotes, emoteSpacing);
|
||||
return std::max(minimumHeight, metrics.tallestHeight + 2);
|
||||
}
|
||||
|
||||
static void drawCenteredEmoteText(OLEDDisplay *display, int x, int y, int rowHeight, const char *text, int emoteSpacing = 2)
|
||||
{
|
||||
// Center mixed text and emotes inside the row height.
|
||||
const auto metrics = graphics::EmoteRenderer::analyzeLine(nullptr, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
|
||||
graphics::numEmotes, emoteSpacing);
|
||||
const int contentHeight = std::max(FONT_HEIGHT_SMALL, metrics.tallestHeight);
|
||||
const int drawY = y + ((rowHeight - contentHeight) / 2);
|
||||
graphics::EmoteRenderer::drawStringWithEmotes(display, x, drawY, text ? text : "", FONT_HEIGHT_SMALL, graphics::emotes,
|
||||
graphics::numEmotes, emoteSpacing, false);
|
||||
}
|
||||
|
||||
static size_t firstWrappedTokenLen(const char *text)
|
||||
{
|
||||
// Fall back to one full emote or one UTF-8 glyph when width is tiny.
|
||||
if (!text || !*text)
|
||||
return 0;
|
||||
|
||||
const size_t textLen = strlen(text);
|
||||
size_t matchLen = 0;
|
||||
if (graphics::EmoteRenderer::findEmoteAt(text, textLen, 0, matchLen, graphics::emotes, graphics::numEmotes))
|
||||
return matchLen;
|
||||
|
||||
return graphics::EmoteRenderer::utf8CharLen(static_cast<uint8_t>(text[0]));
|
||||
}
|
||||
|
||||
static void drawWrappedEmoteText(OLEDDisplay *display, int x, int y, const char *text, int maxWidth, int minimumRowHeight,
|
||||
int emoteSpacing = 2)
|
||||
{
|
||||
// Wrap onto multiple rows without splitting emotes.
|
||||
if (!display || !text || maxWidth <= 0)
|
||||
return;
|
||||
|
||||
constexpr size_t kLineBufferSize = 256;
|
||||
char lineBuffer[kLineBufferSize];
|
||||
const size_t textLen = strlen(text);
|
||||
size_t offset = 0;
|
||||
int yCursor = y;
|
||||
|
||||
while (offset < textLen) {
|
||||
size_t copied = graphics::EmoteRenderer::truncateToWidth(display, text + offset, lineBuffer, sizeof(lineBuffer), maxWidth,
|
||||
"", graphics::emotes, graphics::numEmotes, emoteSpacing);
|
||||
size_t consumed = copied;
|
||||
|
||||
if (copied == 0) {
|
||||
consumed = firstWrappedTokenLen(text + offset);
|
||||
if (consumed == 0)
|
||||
break;
|
||||
|
||||
const size_t fallbackLen = std::min(consumed, sizeof(lineBuffer) - 1);
|
||||
memcpy(lineBuffer, text + offset, fallbackLen);
|
||||
lineBuffer[fallbackLen] = '\0';
|
||||
consumed = fallbackLen;
|
||||
} else if (text[offset + copied] != '\0') {
|
||||
// Prefer wrapping at the last space when a full line does not fit.
|
||||
size_t lastSpace = copied;
|
||||
while (lastSpace > 0 && lineBuffer[lastSpace - 1] != ' ')
|
||||
--lastSpace;
|
||||
|
||||
if (lastSpace > 0) {
|
||||
consumed = lastSpace;
|
||||
while (consumed > 0 && lineBuffer[consumed - 1] == ' ')
|
||||
--consumed;
|
||||
lineBuffer[consumed] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
if (lineBuffer[0]) {
|
||||
const int rowHeight = getRowHeightForEmoteText(lineBuffer, minimumRowHeight, emoteSpacing);
|
||||
drawCenteredEmoteText(display, x, yCursor, rowHeight, lineBuffer, emoteSpacing);
|
||||
yCursor += rowHeight;
|
||||
}
|
||||
|
||||
offset += std::max<size_t>(consumed, 1);
|
||||
while (offset < textLen && text[offset] == ' ')
|
||||
++offset;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Main input event dispatcher for CannedMessageModule.
|
||||
* Routes keyboard/button/touch input to the correct handler based on the current runState.
|
||||
@@ -491,18 +515,16 @@ bool CannedMessageModule::handleTabSwitch(const InputEvent *event)
|
||||
if (event->kbchar != 0x09)
|
||||
return false;
|
||||
|
||||
updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
|
||||
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION);
|
||||
const cannedMessageModuleRunState targetState = (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
|
||||
? CANNED_MESSAGE_RUN_STATE_FREETEXT
|
||||
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION;
|
||||
|
||||
destIndex = 0;
|
||||
scrollIndex = 0;
|
||||
// RESTORE THIS!
|
||||
if (runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
|
||||
if (targetState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION)
|
||||
updateDestinationSelectionList();
|
||||
|
||||
updateState((runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) ? CANNED_MESSAGE_RUN_STATE_FREETEXT
|
||||
: CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION,
|
||||
true);
|
||||
updateState(targetState, true);
|
||||
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
@@ -1686,55 +1708,51 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
|
||||
int xOffset = 0;
|
||||
int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset;
|
||||
char entryText[64] = "";
|
||||
std::string entryText;
|
||||
|
||||
// Draw Channels First
|
||||
if (itemIndex < numActiveChannels) {
|
||||
uint8_t channelIndex = this->activeChannelIndices[itemIndex];
|
||||
snprintf(entryText, sizeof(entryText), "#%s", channels.getName(channelIndex));
|
||||
const char *channelName = channels.getName(channelIndex);
|
||||
entryText = std::string("#") + (channelName ? channelName : "?");
|
||||
}
|
||||
// Then Draw Nodes
|
||||
else {
|
||||
int nodeIndex = itemIndex - numActiveChannels;
|
||||
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(this->filteredNodes.size())) {
|
||||
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
|
||||
if (node && node->user.long_name) {
|
||||
strncpy(entryText, node->user.long_name, sizeof(entryText) - 1);
|
||||
entryText[sizeof(entryText) - 1] = '\0';
|
||||
if (node) {
|
||||
if (display->getWidth() <= 64) {
|
||||
entryText = node->user.short_name;
|
||||
} else if (node->user.long_name[0]) {
|
||||
entryText = node->user.long_name;
|
||||
} else {
|
||||
entryText = node->user.short_name;
|
||||
}
|
||||
}
|
||||
|
||||
int availWidth = display->getWidth() -
|
||||
((graphics::currentResolution == graphics::ScreenResolution::High) ? 40 : 20) -
|
||||
((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, "...");
|
||||
}
|
||||
char truncatedEntry[96];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
||||
availWidth);
|
||||
entryText = truncatedEntry;
|
||||
|
||||
// Prepend "* " if this is a favorite
|
||||
if (node && node->is_favorite) {
|
||||
size_t len = strlen(entryText);
|
||||
if (len + 2 < sizeof(entryText)) {
|
||||
memmove(entryText + 2, entryText, len + 1);
|
||||
entryText[0] = '*';
|
||||
entryText[1] = ' ';
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
if (display->getWidth() <= 64) {
|
||||
snprintf(entryText, sizeof(entryText), "%s", node->user.short_name);
|
||||
}
|
||||
entryText = "* " + entryText;
|
||||
}
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
||||
availWidth);
|
||||
entryText = truncatedEntry;
|
||||
}
|
||||
}
|
||||
|
||||
if (strlen(entryText) == 0 || strcmp(entryText, "Unknown") == 0)
|
||||
strcpy(entryText, "?");
|
||||
if (entryText.empty() || entryText == "Unknown")
|
||||
entryText = "?";
|
||||
|
||||
// Highlight background (if selected)
|
||||
if (itemIndex == destIndex) {
|
||||
@@ -1744,7 +1762,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
}
|
||||
|
||||
// Draw entry text
|
||||
display->drawString(xOffset + 2, yOffset, entryText);
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false);
|
||||
display->setColor(WHITE);
|
||||
|
||||
// Draw key icon (after highlight)
|
||||
@@ -1785,15 +1803,10 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
|
||||
{
|
||||
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 labelGap = 4;
|
||||
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 maxEmoteHeight = graphics::EmoteRenderer::maxEmoteHeight();
|
||||
const int rowHeight = maxEmoteHeight + 2;
|
||||
|
||||
// Place header at top, then compute start of emote list
|
||||
@@ -1840,14 +1853,16 @@ void CannedMessageModule::drawEmotePickerScreen(OLEDDisplay *display, OLEDDispla
|
||||
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 bitmap (left), centered inside the row
|
||||
int labelStartX = x + bitmapGapX;
|
||||
const int emoteY = rowY + ((rowHeight - emote.height) / 2);
|
||||
display->drawXbm(labelStartX, emoteY, emote.width, emote.height, emote.bitmap);
|
||||
labelStartX += emote.width;
|
||||
|
||||
// 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);
|
||||
display->drawString(labelStartX + labelGap, labelY, emote.label);
|
||||
|
||||
if (emoteIdx == emotePickerIndex)
|
||||
display->setColor(WHITE);
|
||||
@@ -2007,91 +2022,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
{
|
||||
int inputY = 0 + y + FONT_HEIGHT_SMALL;
|
||||
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 = tokenizeMessageWithEmotes(msg);
|
||||
|
||||
// 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) {
|
||||
// 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 < static_cast<int>(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) {
|
||||
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++;
|
||||
}
|
||||
} 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 (const auto &line : lines) {
|
||||
int nextX = x;
|
||||
for (const auto &token : line) {
|
||||
if (token.first) {
|
||||
// Emote rendering centralized in helper
|
||||
renderEmote(display, nextX, yLine, rowHeight, token.second);
|
||||
} else {
|
||||
display->drawString(nextX, yLine, token.second);
|
||||
nextX += display->getStringWidth(token.second);
|
||||
}
|
||||
}
|
||||
yLine += rowHeight;
|
||||
}
|
||||
drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL);
|
||||
}
|
||||
#endif
|
||||
return;
|
||||
@@ -2106,7 +2037,6 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
const int baseRowSpacing = FONT_HEIGHT_SMALL - 4;
|
||||
|
||||
int topMsg;
|
||||
std::vector<int> rowHeights;
|
||||
int _visibleRows;
|
||||
|
||||
// Draw header (To: ...)
|
||||
@@ -2122,36 +2052,15 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
: 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];
|
||||
int rowHeight = getRowHeightForEmoteText(msg, baseRowSpacing);
|
||||
bool _highlight = (msgIdx == currentMessageIndex);
|
||||
|
||||
// Multi-emote tokenization
|
||||
std::vector<std::pair<bool, String>> tokens = tokenizeMessageWithEmotes(msg);
|
||||
|
||||
// Vertically center based on rowHeight
|
||||
int textYOffset = (rowHeight - FONT_HEIGHT_SMALL) / 2;
|
||||
|
||||
@@ -2168,17 +2077,8 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
int nextX = x + (_highlight ? 2 : 0);
|
||||
#endif
|
||||
|
||||
// Draw all tokens left to right
|
||||
for (const auto &token : tokens) {
|
||||
if (token.first) {
|
||||
// Emote rendering centralized in helper
|
||||
renderEmote(display, nextX, lineY, rowHeight, token.second);
|
||||
} else {
|
||||
// Text
|
||||
display->drawString(nextX, lineY + textYOffset, token.second);
|
||||
nextX += display->getStringWidth(token.second);
|
||||
}
|
||||
}
|
||||
if (msg && *msg)
|
||||
drawCenteredEmoteText(display, nextX, lineY, rowHeight, msg);
|
||||
#ifndef USE_EINK
|
||||
if (_highlight)
|
||||
display->setColor(WHITE);
|
||||
|
||||
Reference in New Issue
Block a user