Files
meshtastic_firmware/src/graphics/draw/MessageRenderer.cpp
T

1201 lines
43 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "configuration.h"
#if HAS_SCREEN
#include "MessageRenderer.h"
// Core includes
#include "MessageStore.h"
#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"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/emotes.h"
#include "main.h"
#include "meshUtils.h"
#include "modules/CannedMessageModule.h"
#include <string>
#include <vector>
// External declarations
extern bool hasUnreadMessage;
extern std::unique_ptr<graphics::Screen> screen;
using graphics::Emote;
using graphics::emotes;
using graphics::numEmotes;
namespace graphics
{
namespace MessageRenderer
{
static std::vector<std::string> cachedLines;
static std::vector<int> cachedHeights;
static bool manualScrolling = false;
// Scroll state (file scope so we can reset on new message)
float scrollY = 0.0f;
uint32_t lastTime = 0;
uint32_t scrollStartDelay = 0;
uint32_t pauseStart = 0;
bool waitingToReset = false;
bool scrollStarted = false;
static bool didReset = false;
static constexpr int MESSAGE_BLOCK_GAP = 6;
void scrollUp()
{
manualScrolling = true;
scrollY -= 12;
if (scrollY < 0)
scrollY = 0;
}
void scrollDown()
{
manualScrolling = true;
int totalHeight = 0;
for (int h : cachedHeights)
totalHeight += h;
int visibleHeight = screen->getHeight() - (FONT_HEIGHT_SMALL * 2);
int maxScroll = totalHeight - visibleHeight;
if (maxScroll < 0)
maxScroll = 0;
scrollY += 12;
if (scrollY > maxScroll)
scrollY = maxScroll;
}
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}
// Reset scroll state when new messages arrive
void resetScrollState()
{
scrollY = 0.0f;
scrollStarted = false;
waitingToReset = false;
scrollStartDelay = millis();
lastTime = millis();
manualScrolling = false;
didReset = false;
}
void nudgeScroll(int8_t direction)
{
if (direction == 0)
return;
if (cachedHeights.empty()) {
scrollY = 0.0f;
return;
}
OLEDDisplay *display = (screen != nullptr) ? screen->getDisplayDevice() : nullptr;
const int displayHeight = display ? display->getHeight() : 64;
const int navHeight = FONT_HEIGHT_SMALL;
const int usableHeight = std::max(0, displayHeight - navHeight);
int totalHeight = 0;
for (int h : cachedHeights)
totalHeight += h;
if (totalHeight <= usableHeight) {
scrollY = 0.0f;
return;
}
const int scrollStop = std::max(0, totalHeight - usableHeight + cachedHeights.back());
const int step = std::max(FONT_HEIGHT_SMALL, usableHeight / 3);
float newScroll = scrollY + static_cast<float>(direction) * static_cast<float>(step);
if (newScroll < 0.0f)
newScroll = 0.0f;
if (newScroll > scrollStop)
newScroll = static_cast<float>(scrollStop);
if (newScroll != scrollY) {
scrollY = newScroll;
waitingToReset = false;
scrollStarted = false;
scrollStartDelay = millis();
lastTime = millis();
}
}
// Fully free cached message data from heap
void clearMessageCache()
{
std::vector<std::string>().swap(cachedLines);
std::vector<int>().swap(cachedHeights);
// Reset scroll so we rebuild cleanly next time we enter the screen
resetScrollState();
}
// Current thread state
static ThreadMode currentMode = ThreadMode::ALL;
static int currentChannel = -1;
static uint32_t currentPeer = 0;
// Registry of seen threads for manual toggle
static std::vector<int> seenChannels;
static std::vector<uint32_t> seenPeers;
// Public helper so menus / store can clear stale registries
void clearThreadRegistries()
{
seenChannels.clear();
seenPeers.clear();
}
// Setter so other code can switch threads
void setThreadMode(ThreadMode mode, int channel /* = -1 */, uint32_t peer /* = 0 */)
{
currentMode = mode;
currentChannel = channel;
currentPeer = peer;
didReset = false; // force reset when mode changes
// Track channels weve seen
if (mode == ThreadMode::CHANNEL && channel >= 0) {
if (std::find(seenChannels.begin(), seenChannels.end(), channel) == seenChannels.end()) {
seenChannels.push_back(channel);
}
}
// Track DMs weve seen
if (mode == ThreadMode::DIRECT && peer != 0) {
if (std::find(seenPeers.begin(), seenPeers.end(), peer) == seenPeers.end()) {
seenPeers.push_back(peer);
}
}
}
ThreadMode getThreadMode()
{
return currentMode;
}
int getThreadChannel()
{
return currentChannel;
}
uint32_t getThreadPeer()
{
return currentPeer;
}
// Accessors for menuHandler
const std::vector<int> &getSeenChannels()
{
return seenChannels;
}
const std::vector<uint32_t> &getSeenPeers()
{
return seenPeers;
}
static int centerYForRow(int y, int size)
{
int midY = y + (FONT_HEIGHT_SMALL / 2);
return midY - (size / 2);
}
// Helpers for drawing status marks (thickened strokes)
static void drawCheckMark(OLEDDisplay *display, int x, int y, int size)
{
int topY = centerYForRow(y, size);
display->setColor(WHITE);
display->drawLine(x, topY + size / 2, x + size / 3, topY + size);
display->drawLine(x, topY + size / 2 + 1, x + size / 3, topY + size + 1);
display->drawLine(x + size / 3, topY + size, x + size, topY);
display->drawLine(x + size / 3, topY + size + 1, x + size, topY + 1);
}
static void drawXMark(OLEDDisplay *display, int x, int y, int size = 8)
{
int topY = centerYForRow(y, size);
display->setColor(WHITE);
display->drawLine(x, topY, x + size, topY + size);
display->drawLine(x, topY + 1, x + size, topY + size + 1);
display->drawLine(x + size, topY, x, topY + size);
display->drawLine(x + size, topY + 1, x, topY + size + 1);
}
static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
{
int r = size / 2;
int centerY = centerYForRow(y, size) + r;
int centerX = x + r;
display->setColor(WHITE);
display->drawCircle(centerX, centerY, r);
display->drawLine(centerX, centerY - 2, centerX, centerY);
display->setPixel(centerX, centerY + 2);
display->drawLine(centerX - 1, centerY - 4, centerX + 1, centerY - 4);
}
static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
{
return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
}
struct MessageBlock {
size_t start;
size_t end;
bool mine;
};
#if GRAPHICS_TFT_COLORING_ENABLED
static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
{
uint16_t bubbleOnColor;
uint16_t bubbleOffColor;
if (themeId == ThemeID::Blue) {
bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
} else {
bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
}
setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
}
static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
{
if (w <= 0 || h <= 0) {
return;
}
if (radius <= 0 || w < 3 || h < 3) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
return;
}
// Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
// scrolling long message lists (which can flatten older bubble corners).
int capRows = 0;
if (radius >= 4 && h >= 5) {
capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
} else if (radius >= 2 && h >= 3) {
capRows = 1; // 3 regions total
}
if (capRows <= 0) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
return;
}
for (int row = 0; row < capRows; ++row) {
int inset = 0;
if (radius >= 4) {
inset = (row == 0) ? 2 : 1;
} else if (radius >= 2) {
inset = 1;
}
const int stripW = w - (inset * 2);
if (stripW <= 0) {
continue;
}
const int topY = y + row;
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);
const int bottomY = y + h - 1 - row;
if (bottomY != topY) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
}
}
const int middleY = y + capRows;
const int middleH = h - (capRows * 2);
if (middleH > 0) {
registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
}
}
#endif
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
{
if (isHeaderLine) {
return lineTopY + (FONT_HEIGHT_SMALL - 1);
}
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;
return iconTop + tallest - 1;
}
static std::vector<MessageBlock> buildMessageBlocks(const std::vector<bool> &isHeaderVec, const std::vector<bool> &isMineVec)
{
std::vector<MessageBlock> blocks;
if (isHeaderVec.empty())
return blocks;
size_t start = 0;
bool mine = isMineVec[0];
for (size_t i = 1; i < isHeaderVec.size(); ++i) {
if (isHeaderVec[i]) {
MessageBlock b;
b.start = start;
b.end = i - 1;
b.mine = mine;
blocks.push_back(b);
start = i;
mine = isMineVec[i];
}
}
MessageBlock last;
last.start = start;
last.end = isHeaderVec.size() - 1;
last.mine = mine;
blocks.push_back(last);
return blocks;
}
static void drawMessageScrollbar(OLEDDisplay *display, int visibleHeight, int totalHeight, int scrollOffset, int startY)
{
if (totalHeight <= visibleHeight)
return; // no scrollbar needed
int scrollbarX = display->getWidth() - 2;
int scrollbarHeight = visibleHeight;
int thumbHeight = std::max(6, (scrollbarHeight * visibleHeight) / totalHeight);
int maxScroll = std::max(1, totalHeight - visibleHeight);
int thumbY = startY + (scrollbarHeight - thumbHeight) * scrollOffset / maxScroll;
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
}
void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
// Ensure any boot-relative timestamps are upgraded if RTC is valid
messageStore.upgradeBootRelativeTimestamps();
if (!didReset) {
resetScrollState();
didReset = true;
}
// Clear the unread message indicator when viewing the message
hasUnreadMessage = false;
// Filter messages based on thread mode
std::deque<StoredMessage> filtered;
for (const auto &m : messageStore.getLiveMessages()) {
if (!messageStore.isMessageVisible(m))
continue;
bool include = false;
switch (currentMode) {
case ThreadMode::ALL:
include = true;
break;
case ThreadMode::CHANNEL:
if (m.type == MessageType::BROADCAST && (int)m.channelIndex == currentChannel)
include = true;
break;
case ThreadMode::DIRECT:
if (m.dest != NODENUM_BROADCAST && (m.sender == currentPeer || m.dest == currentPeer))
include = true;
break;
}
if (include)
filtered.push_back(m);
}
display->clear();
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
const int navHeight = FONT_HEIGHT_SMALL;
const int scrollBottom = SCREEN_HEIGHT - navHeight;
const int usableHeight = scrollBottom;
constexpr int LEFT_MARGIN = 2;
constexpr int RIGHT_MARGIN = 2;
constexpr int SCROLLBAR_WIDTH = 3;
constexpr int BUBBLE_PAD_X = 3;
constexpr int BUBBLE_PAD_Y = 4;
constexpr int BUBBLE_RADIUS = 4;
constexpr int BUBBLE_MIN_W = 24;
constexpr int BUBBLE_TEXT_INDENT = 2;
// Check if bubbles are enabled
const bool showBubbles = config.display.enable_message_bubbles;
const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;
// Derived widths
const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - (showBubbles ? (BUBBLE_PAD_X * 2) : 0);
const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;
// Title string depending on mode
char titleStr[48];
snprintf(titleStr, sizeof(titleStr), "Messages");
switch (currentMode) {
case ThreadMode::ALL:
snprintf(titleStr, sizeof(titleStr), "Messages");
break;
case ThreadMode::CHANNEL: {
const char *cname = channels.getName(currentChannel);
if (cname && cname[0]) {
snprintf(titleStr, sizeof(titleStr), "#%s", cname);
} else {
snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
}
break;
}
case ThreadMode::DIRECT: {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
if (nodeInfoLiteHasUser(node) && node->short_name[0]) {
snprintf(titleStr, sizeof(titleStr), "@%s", node->short_name);
} else {
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
}
break;
}
}
if (filtered.empty()) {
// If current conversation is empty go back to ALL view
if (currentMode != ThreadMode::ALL) {
setThreadMode(ThreadMode::ALL);
resetScrollState();
return; // Next draw will rerun in ALL mode
}
// Still in ALL mode and no messages at all → show placeholder
graphics::drawCommonHeader(display, x, y, titleStr);
didReset = false;
const char *messageString = "No messages";
int center_text = (SCREEN_WIDTH / 2) - (display->getStringWidth(messageString) / 2);
display->drawString(center_text, getTextPositions(display)[2], messageString);
graphics::drawCommonFooter(display, x, y);
return;
}
// Build lines for filtered messages (newest first)
std::vector<std::string> allLines;
std::vector<bool> isMine; // track alignment
std::vector<bool> isHeader; // track header lines
std::vector<AckStatus> ackForLine;
// Hard limit on total cached lines to prevent unbounded growth from a single long message.
// Reserve to the actual cache cap up front, because a single message can expand to many more
// wrapped display lines than a small per-message estimate would predict. For a display
// rendering only ~5-30 lines at a time, caching more than this limit wastes heap. Stop
// appending once we reach MAX_CACHED_LINES to prevent a single message from blowing out the
// heap.
constexpr size_t MAX_CACHED_LINES = 100U; // ~5-6KB for std::string overhead on 32-bit (if each ~50-60 bytes avg)
allLines.reserve(MAX_CACHED_LINES);
isMine.reserve(MAX_CACHED_LINES);
isHeader.reserve(MAX_CACHED_LINES);
ackForLine.reserve(MAX_CACHED_LINES);
for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) {
const auto &m = *it;
// Channel / destination labeling
char chanType[32] = "";
if (currentMode == ThreadMode::ALL) {
if (m.dest == NODENUM_BROADCAST) {
const char *name = channels.getName(m.channelIndex);
if (currentResolution == ScreenResolution::Low || currentResolution == ScreenResolution::UltraLow) {
if (strcmp(name, "ShortTurbo") == 0)
name = "ShortT";
else if (strcmp(name, "ShortSlow") == 0)
name = "ShortS";
else if (strcmp(name, "ShortFast") == 0)
name = "ShortF";
else if (strcmp(name, "MediumSlow") == 0)
name = "MedS";
else if (strcmp(name, "MediumFast") == 0)
name = "MedF";
else if (strcmp(name, "LongSlow") == 0)
name = "LongS";
else if (strcmp(name, "LongFast") == 0)
name = "LongF";
else if (strcmp(name, "LongTurbo") == 0)
name = "LongT";
else if (strcmp(name, "LongMod") == 0)
name = "LongM";
}
snprintf(chanType, sizeof(chanType), "#%s", name);
} else {
snprintf(chanType, sizeof(chanType), "(DM)");
}
}
// Calculate how long ago
uint32_t nowSecs = getValidTime(RTCQuality::RTCQualityDevice, true);
uint32_t seconds = 0;
bool invalidTime = true;
if (m.timestamp > 0 && nowSecs > 0) {
if (nowSecs >= m.timestamp) {
seconds = nowSecs - m.timestamp;
invalidTime = (seconds > 315360000); // >10 years
} else {
uint32_t ahead = m.timestamp - nowSecs;
if (ahead <= 600) { // allow small skew
seconds = 0;
invalidTime = false;
}
}
} else if (m.timestamp > 0 && nowSecs == 0) {
// RTC not valid: only trust boot-relative if same boot
uint32_t bootNow = millis() / 1000;
if (m.isBootRelative && m.timestamp <= bootNow) {
seconds = bootNow - m.timestamp;
invalidTime = false;
} else {
invalidTime = true; // old persisted boot-relative, ignore until healed
}
}
char timeBuf[16];
if (invalidTime) {
snprintf(timeBuf, sizeof(timeBuf), "???");
} else if (seconds < 60) {
snprintf(timeBuf, sizeof(timeBuf), "%us", seconds);
} else if (seconds < 3600) {
snprintf(timeBuf, sizeof(timeBuf), "%um", seconds / 60);
} else if (seconds < 86400) {
snprintf(timeBuf, sizeof(timeBuf), "%uh", seconds / 3600);
} else {
snprintf(timeBuf, sizeof(timeBuf), "%ud", seconds / 86400);
}
// Build header line for this message
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
char senderName[64] = "";
if (nodeInfoLiteHasUser(node)) {
if (node->long_name[0]) {
strncpy(senderName, node->long_name, sizeof(senderName) - 1);
} else if (node->short_name[0]) {
strncpy(senderName, node->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 senderName to who the recipient was
bool mine = (m.sender == nodeDB->getNodeNum());
if (mine && nodeInfoLiteHasUser(node_recipient)) {
if (node_recipient->long_name[0]) {
strncpy(senderName, node_recipient->long_name, sizeof(senderName) - 1);
senderName[sizeof(senderName) - 1] = '\0';
} else if (node_recipient->short_name[0]) {
strncpy(senderName, node_recipient->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 && (!nodeInfoLiteHasUser(node_recipient) || (!node_recipient->long_name[0] && !node_recipient->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) - graphics::UIRenderer::measureStringWithEmotes(display, " *@...");
if (availWidth < 0)
availWidth = 0;
char truncatedSender[64];
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
// Determine signed-message prefix before building the header line, since it needs to go
// at the front of headerStr rather than appended after (strncat only appends at the end).
const char *signPrefix = "";
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
bool is_xeddsa_signed = m.xeddsaSigned;
if (is_xeddsa_signed) {
signPrefix = "*";
}
#endif
// Final header line
char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
}
} else {
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s %s@%s %s" : "%s %s@%s", timeBuf, signPrefix, truncatedSender,
chanType);
}
// Push header line
allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
const char *msgText = MessageStore::getText(m);
int wrapWidth = mine ? rightTextWidth : leftTextWidth;
std::vector<std::string> wrapped = generateLines(display, "", msgText, wrapWidth);
// Per-message wrap-line limit: even if wrapping produces many lines, cap them to prevent
// a single long message from consuming most or all of the cache.
constexpr size_t MAX_WRAPPED_LINES_PER_MSG = 20U;
size_t wrappedCount = 0;
for (auto &ln : wrapped) {
if (allLines.size() >= MAX_CACHED_LINES || wrappedCount >= MAX_WRAPPED_LINES_PER_MSG)
break; // Cache limit or per-message limit reached; stop adding lines from this message
allLines.emplace_back(std::move(ln));
isMine.push_back(mine);
isHeader.push_back(false);
ackForLine.push_back(AckStatus::NONE);
++wrappedCount;
}
}
// Cache lines and heights
cachedLines.swap(allLines);
cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader);
std::vector<MessageBlock> blocks = buildMessageBlocks(isHeader, isMine);
// Scrolling logic (unchanged)
int totalHeight = 0;
for (size_t i = 0; i < cachedHeights.size(); ++i)
totalHeight += cachedHeights[i];
int usableScrollHeight = usableHeight;
int scrollStop = std::max(0, totalHeight - usableScrollHeight + cachedHeights.back());
#ifndef USE_EINK
uint32_t now = millis();
float delta = (now - lastTime) / 400.0f;
lastTime = now;
const float scrollSpeed = 2.0f;
if (scrollStartDelay == 0)
scrollStartDelay = now;
if (!scrollStarted && now - scrollStartDelay > 2000)
scrollStarted = true;
if (!manualScrolling && totalHeight > usableScrollHeight) {
if (scrollStarted) {
if (!waitingToReset) {
scrollY += delta * scrollSpeed;
if (scrollY >= scrollStop) {
scrollY = scrollStop;
waitingToReset = true;
pauseStart = lastTime;
}
} else if (lastTime - pauseStart > 3000) {
scrollY = 0;
waitingToReset = false;
scrollStarted = false;
scrollStartDelay = lastTime;
}
}
} else if (!manualScrolling) {
scrollY = 0;
}
#else
// E-Ink: disable autoscroll
scrollY = 0.0f;
waitingToReset = false;
scrollStarted = false;
lastTime = millis();
#endif
int finalScroll = (int)scrollY;
int yOffset = -finalScroll + getTextPositions(display)[1];
const int contentTop = getTextPositions(display)[1];
const int contentBottom = scrollBottom; // already excludes nav line
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
#if GRAPHICS_TFT_COLORING_ENABLED
const uint32_t themeId = getActiveTheme().id;
// Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
#endif
std::vector<int> lineTop;
lineTop.resize(cachedLines.size());
{
int acc = 0;
for (size_t i = 0; i < cachedLines.size(); ++i) {
lineTop[i] = yOffset + acc;
acc += cachedHeights[i];
}
}
// Draw bubbles (only if enabled)
if (showBubbles) {
for (size_t bi = 0; bi < blocks.size(); ++bi) {
const auto &b = blocks[bi];
if (b.start >= cachedLines.size() || b.end >= cachedLines.size() || b.start > b.end)
continue;
int visualTop = lineTop[b.start];
int topY;
if (isHeader[b.start]) {
// Header start
constexpr int BUBBLE_PAD_TOP_HEADER = 1; // try 1 or 2
topY = visualTop - BUBBLE_PAD_TOP_HEADER;
} else {
// Body start
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;
}
topY = visualTop - BUBBLE_PAD_Y;
}
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
int bottomY = visualBottom + BUBBLE_PAD_Y;
// On high-res screens, keep a 1px gap under the header
if (currentResolution == ScreenResolution::High) {
const int minTopY = contentTop + 1;
if (topY < minTopY) {
// Preserve bubble height when we push it down from the header.
const int shift = minTopY - topY;
topY = minTopY;
bottomY += shift;
}
}
if (bi + 1 < blocks.size()) {
int nextHeaderIndex = (int)blocks[bi + 1].start;
int nextTop = lineTop[nextHeaderIndex];
int maxBottom = nextTop - 1 - bubbleGapY;
if (bottomY > maxBottom)
bottomY = maxBottom;
}
if (bottomY <= topY + 2)
continue;
if (bottomY < contentTop || topY > contentBottom - 1)
continue;
int maxLineW = 0;
for (size_t i = b.start; i <= b.end; ++i) {
int w = 0;
if (isHeader[i]) {
w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
if (b.mine)
w += 12; // room for ACK/NACK/relay mark
} else {
w = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
}
if (w > maxLineW)
maxLineW = w;
}
int bubbleW = std::max(BUBBLE_MIN_W, maxLineW + (textIndent * 2));
int bubbleH = (bottomY - topY) + 1;
int bubbleX = 0;
if (b.mine) {
bubbleX = rightEdge - bubbleW;
} else {
bubbleX = x;
}
if (bubbleX < x)
bubbleX = x;
if (bubbleX + bubbleW > rightEdge)
bubbleW = std::max(1, rightEdge - bubbleX);
// Draw rounded rectangle bubble
if (bubbleW > BUBBLE_RADIUS * 2 && bubbleH > BUBBLE_RADIUS * 2) {
const int r = BUBBLE_RADIUS;
const int bx = bubbleX;
const int by = topY;
const int bw = bubbleW;
const int bh = bubbleH;
#if GRAPHICS_TFT_COLORING_ENABLED
const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
setDarkModeBubbleRoleColors(themeId, b.mine);
registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
}
#endif
if (drawBubbleOutline) {
// Draw the 4 corner arcs using drawCircleQuads
display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
// Draw the 4 edges between corners
display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
}
} else if (bubbleW > 1 && bubbleH > 1) {
// Fallback to simple rectangle for very small bubbles
#if GRAPHICS_TFT_COLORING_ENABLED
const bool drawBubbleOutline = !useDarkModeBubbleFill;
#else
const bool drawBubbleOutline = true;
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
setDarkModeBubbleRoleColors(themeId, b.mine);
registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
}
#endif
if (drawBubbleOutline) {
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
}
}
}
} // end if (showBubbles)
#if GRAPHICS_TFT_COLORING_ENABLED
if (useDarkModeBubbleFill) {
// Restore theme role defaults so other screens keep their intended palette.
loadThemeDefaults();
}
#endif
// Render visible lines
int lineY = yOffset;
for (size_t i = 0; i < cachedLines.size(); ++i) {
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
if (isHeader[i]) {
int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
int headerX;
if (isMine[i]) {
// push header left to avoid overlap with scrollbar
headerX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - w - (showBubbles ? textIndent : 0);
if (headerX < LEFT_MARGIN)
headerX = LEFT_MARGIN;
} else {
headerX = x + textIndent;
}
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
true);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
int underlineW = w;
int maxW = rightEdge - headerX;
if (maxW < 0)
maxW = 0;
if (underlineW > maxW)
underlineW = maxW;
for (int px = 0; px < underlineW; ++px) {
display->setPixel(headerX + px, underlineY);
}
// Draw ACK/NACK mark for our own messages
if (isMine[i]) {
int markX = headerX - 10;
int markY = lineY;
if (ackForLine[i] == AckStatus::ACKED) {
// Destination ACK
drawCheckMark(display, markX, markY, 8);
} else if (ackForLine[i] == AckStatus::NACKED || ackForLine[i] == AckStatus::TIMEOUT) {
// Failure or timeout
drawXMark(display, markX, markY, 8);
} else if (ackForLine[i] == AckStatus::RELAYED) {
// Relay ACK
drawRelayMark(display, markX, markY, 8);
}
// AckStatus::NONE → show nothing
}
} else {
// Render message line
if (isMine[i]) {
// Calculate actual rendered width including emotes
int renderedWidth = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
int rightX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - renderedWidth - (showBubbles ? textIndent : 0);
if (rightX < LEFT_MARGIN)
rightX = LEFT_MARGIN;
drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes);
} else {
drawStringWithEmotes(display, x + textIndent, lineY, cachedLines[i], emotes, numEmotes);
}
}
}
lineY += cachedHeights[i];
}
// Draw scrollbar
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, getTextPositions(display)[1]);
graphics::drawCommonHeader(display, x, y, titleStr);
graphics::drawCommonFooter(display, x, y);
}
std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerStr, const char *messageBuf, int textWidth)
{
std::vector<std::string> lines;
// Only push headerStr if it's not empty (prevents extra blank line after headers)
if (headerStr && headerStr[0] != '\0') {
lines.push_back(std::string(headerStr));
}
std::string line, word;
for (int i = 0; messageBuf[i]; ++i) {
char ch = messageBuf[i];
if ((unsigned char)messageBuf[i] == 0xE2 && (unsigned char)messageBuf[i + 1] == 0x80 &&
(unsigned char)messageBuf[i + 2] == 0x99) {
ch = '\''; // plain apostrophe
i += 2; // skip over the extra UTF-8 bytes
}
if (ch == '\n') {
if (!word.empty())
line += word;
if (!line.empty())
lines.push_back(line);
line.clear();
word.clear();
} else if (ch == ' ') {
line += word + ' ';
word.clear();
} else {
word += ch;
std::string test = line + word;
uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
if (strWidth > textWidth) {
if (!line.empty())
lines.push_back(line);
line = word;
word.clear();
}
}
}
if (!word.empty())
line += word;
if (!line.empty())
lines.push_back(line);
return lines;
}
std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, const Emote *emotes,
const std::vector<bool> &isHeaderVec)
{
// Tunables for layout control
constexpr int HEADER_UNDERLINE_GAP = 0; // space between underline and first body line
constexpr int HEADER_UNDERLINE_PIX = 1; // underline thickness (1px row drawn)
constexpr int BODY_LINE_LEADING = -4; // default vertical leading for normal body lines
constexpr int EMOTE_PADDING_ABOVE = 4; // space above emote line (added to line above)
constexpr int EMOTE_PADDING_BELOW = 3; // space below emote line (added to emote line)
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 int baseHeight = FONT_HEIGHT_SMALL;
int lineHeight = baseHeight;
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
lineHeight = baseHeight + HEADER_UNDERLINE_PIX + HEADER_UNDERLINE_GAP;
} else {
// Base spacing for normal lines
int desiredBody = baseHeight + BODY_LINE_LEADING;
if (hasEmote) {
// Emote line: add overshoot + bottom padding
int overshoot = std::max(0, tallestEmote - baseHeight);
lineHeight = desiredBody + overshoot + EMOTE_PADDING_BELOW;
} else {
// Regular line: no emote → standard spacing
lineHeight = desiredBody;
// If next line has an emote → add top padding *here*
if (nextHasEmote) {
lineHeight += EMOTE_PADDING_ABOVE;
}
}
// Add block gap if next is a header
if (idx + 1 < lines.size() && isHeaderVec[idx + 1]) {
lineHeight += MESSAGE_BLOCK_GAP;
}
}
rowHeights.push_back(lineHeight);
}
return rowHeights;
}
void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const meshtastic_MeshPacket &packet)
{
if (packet.from != 0) {
hasUnreadMessage = true;
const bool suppressBanner = cannedMessageModule && cannedMessageModule->isFreeTextActive();
// Determine if message belongs to a muted channel
bool isChannelMuted = false;
if (sm.type == MessageType::BROADCAST) {
const meshtastic_Channel channel = channels.getByIndex(packet.channel ? packet.channel : channels.getPrimaryIndex());
if (channel.settings.has_module_settings && channel.settings.module_settings.is_muted)
isChannelMuted = true;
}
// Banner logic
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
char longName[64] = "?";
if (nodeInfoLiteHasUser(node)) {
if (node->long_name[0]) {
strncpy(longName, node->long_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
} else if (node->short_name[0]) {
strncpy(longName, node->short_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
}
}
int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
if (availWidth < 0)
availWidth = 0;
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];
bool isAlert = false;
// Check if alert detection is enabled via external notification module
if (moduleConfig.external_notification.alert_bell || moduleConfig.external_notification.alert_bell_vibra ||
moduleConfig.external_notification.alert_bell_buzzer) {
for (size_t i = 0; i < packet.decoded.payload.size && i < 100; i++) {
if (msgRaw[i] == '\x07') {
isAlert = true;
break;
}
}
}
if (isAlert) {
if (truncatedLongName[0])
snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
else
strcpy(banner, "Alert Received");
} else {
// Skip muted channels unless it's an alert
if (isChannelMuted)
return;
if (truncatedLongName[0]) {
if (currentResolution == ScreenResolution::UltraLow) {
strcpy(banner, "New Message");
} else {
snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
}
} else
strcpy(banner, "New Message");
}
// Append context (which channel or DM) so the banner shows where the message arrived
{
char contextBuf[64] = "";
if (sm.type == MessageType::BROADCAST) {
const char *cname = channels.getName(sm.channelIndex);
if (cname && cname[0])
snprintf(contextBuf, sizeof(contextBuf), "in #%s", cname);
else
snprintf(contextBuf, sizeof(contextBuf), "in Ch%d", sm.channelIndex);
}
if (contextBuf[0]) {
size_t cur = strlen(banner);
if (cur + 1 < sizeof(banner)) {
if (cur > 0 && banner[cur - 1] != '\n') {
banner[cur] = '\n';
banner[cur + 1] = '\0';
cur++;
}
strncat(banner, contextBuf, sizeof(banner) - cur - 1);
}
}
}
// Shorter banner if already in a conversation (Channel or Direct)
bool inThread = (getThreadMode() != ThreadMode::ALL);
if (!suppressBanner && shouldWakeOnReceivedMessage()) {
screen->setOn(true);
}
if (!suppressBanner) {
screen->showSimpleBanner(banner, inThread ? 1000 : 3000);
}
}
// Always focus into the correct conversation thread when a message with real text arrives
const char *msgText = MessageStore::getText(sm);
if (msgText && msgText[0] != '\0') {
setThreadFor(sm, packet);
}
// Reset scroll for a clean start
resetScrollState();
}
void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
{
if (packet.to == 0 || packet.to == NODENUM_BROADCAST) {
setThreadMode(ThreadMode::CHANNEL, sm.channelIndex);
} else {
uint32_t localNode = nodeDB->getNodeNum();
uint32_t peer = (sm.sender == localNode) ? packet.to : sm.sender;
setThreadMode(ThreadMode::DIRECT, -1, peer);
}
}
} // namespace MessageRenderer
} // namespace graphics
#endif