diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index 9e38f46b1..71b868e8a 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -28,6 +28,8 @@ along with this program. If not, see .
#include
#include "DisplayFormatters.h"
+#include "draw/MessageRenderer.h"
+#include "draw/NodeListRenderer.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
@@ -60,6 +62,12 @@ along with this program. If not, see .
using graphics::Emote;
using graphics::emotes;
using graphics::numEmotes;
+using graphics::NodeListRenderer::drawDistanceScreen;
+using graphics::NodeListRenderer::drawDynamicNodeListScreen;
+using graphics::NodeListRenderer::drawHopSignalScreen;
+using graphics::NodeListRenderer::drawLastHeardScreen;
+using graphics::NodeListRenderer::drawNodeListWithCompasses;
+using graphics::NodeListRenderer::drawScaledXBitmap16x16;
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
#include "mesh/wifi/WiFiAPClient.h"
@@ -189,24 +197,10 @@ int formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *dis
// Usage: int stringWidth = formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display);
// End Functions to write date/time to the screen
-void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display)
-{
- for (int row = 0; row < height; row++) {
- uint8_t rowMask = (1 << row);
- for (int col = 0; col < width; col++) {
- uint8_t colData = pgm_read_byte(&bitmapXBM[col]);
- if (colData & rowMask) {
- // Note: rows become X, columns become Y after transpose
- display->fillRect(x + row * 2, y + col * 2, 2, 2);
- }
- }
- }
-}
-
#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2)
// Check if the display can render a string (detect special chars; emoji)
-static bool haveGlyphs(const char *str)
+bool haveGlyphs(const char *str)
{
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
// Don't want to make any assumptions about custom language support
@@ -1186,6 +1180,8 @@ bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int
return validCached;
}
+namespace UIRenderer
+{
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
int cursorX = x;
@@ -1285,228 +1281,14 @@ void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string
}
}
}
+} // namespace UIRenderer
// ****************************
// * Text Message Screen *
// ****************************
void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
- // Clear the unread message indicator when viewing the message
- hasUnreadMessage = false;
-
- const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
- const char *msg = reinterpret_cast(mp.decoded.payload.bytes);
-
- 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;
- const int textWidth = SCREEN_WIDTH;
- const int cornerRadius = 2;
-
- bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
- bool isBold = config.display.heading_bold;
-
- // === Header Construction ===
- meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
- char headerStr[80];
- const char *sender = "???";
- if (node && node->has_user) {
- if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) {
- sender = node->user.long_name;
- } else {
- sender = node->user.short_name;
- }
- }
- uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
- uint8_t timestampHours, timestampMinutes;
- int32_t daysAgo;
- bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo);
-
- if (useTimestamp && minutes >= 15 && daysAgo == 0) {
- std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At";
- if (config.display.use_12h_clock) {
- bool isPM = timestampHours >= 12;
- timestampHours = timestampHours % 12;
- if (timestampHours == 0)
- timestampHours = 12;
- snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes,
- isPM ? "p" : "a", sender);
- } else {
- snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes,
- sender);
- }
- } else {
- snprintf(headerStr, sizeof(headerStr), "%s ago from %s", screen->drawTimeDelta(days, hours, minutes, seconds).c_str(),
- sender);
- }
-
-#ifndef EXCLUDE_EMOJI
- // === Bounce animation setup ===
- static uint32_t lastBounceTime = 0;
- static int bounceY = 0;
- const int bounceRange = 2; // Max pixels to bounce up/down
- const int bounceInterval = 60; // How quickly to change bounce direction (ms)
-
- uint32_t now = millis();
- if (now - lastBounceTime >= bounceInterval) {
- lastBounceTime = now;
- bounceY = (bounceY + 1) % (bounceRange * 2);
- }
- for (int i = 0; i < numEmotes; ++i) {
- const Emote &e = emotes[i];
- if (strcmp(msg, e.label) == 0) {
- // Draw the header
- if (isInverted) {
- drawRoundedHighlight(display, x, 0, SCREEN_WIDTH, FONT_HEIGHT_SMALL - 1, cornerRadius);
- display->setColor(BLACK);
- display->drawString(x + 3, 0, headerStr);
- if (isBold)
- display->drawString(x + 4, 0, headerStr);
- display->setColor(WHITE);
- } else {
- display->drawString(x, 0, headerStr);
- if (SCREEN_WIDTH > 128) {
- display->drawLine(0, 20, SCREEN_WIDTH, 20);
- } else {
- display->drawLine(0, 14, SCREEN_WIDTH, 14);
- }
- }
-
- // Center the emote below header + apply bounce
- int remainingHeight = SCREEN_HEIGHT - FONT_HEIGHT_SMALL - navHeight;
- int emoteY = FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange;
- display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap);
- return;
- }
- }
-#endif
-
- // === Word-wrap and build line list ===
- char messageBuf[237];
- snprintf(messageBuf, sizeof(messageBuf), "%s", msg);
-
- std::vector lines;
- lines.push_back(std::string(headerStr)); // Header line is always first
-
- std::string line, word;
- for (int i = 0; messageBuf[i]; ++i) {
- char ch = messageBuf[i];
- 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;
- if (display->getStringWidth(test.c_str()) > textWidth + 4) {
- if (!line.empty())
- lines.push_back(line);
- line = word;
- word.clear();
- }
- }
- }
- if (!word.empty())
- line += word;
- if (!line.empty())
- lines.push_back(line);
-
- // === Scrolling logic ===
- std::vector rowHeights;
-
- for (const auto &line : lines) {
- int maxHeight = FONT_HEIGHT_SMALL;
- for (int i = 0; i < numEmotes; ++i) {
- const Emote &e = emotes[i];
- if (line.find(e.label) != std::string::npos) {
- if (e.height > maxHeight)
- maxHeight = e.height;
- }
- }
- rowHeights.push_back(maxHeight);
- }
- int totalHeight = 0;
- for (size_t i = 1; i < rowHeights.size(); ++i) {
- totalHeight += rowHeights[i];
- }
- int usableScrollHeight = usableHeight - rowHeights[0]; // remove header height
- int scrollStop = std::max(0, totalHeight - usableScrollHeight);
-
- static float scrollY = 0.0f;
- static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0;
- static bool waitingToReset = false, scrollStarted = false;
-
- // === Smooth scrolling adjustment ===
- // You can tweak this divisor to change how smooth it scrolls.
- // Lower = smoother, but can feel slow.
- float delta = (now - lastTime) / 400.0f;
- lastTime = now;
-
- const float scrollSpeed = 2.0f; // pixels per second
-
- // Delay scrolling start by 2 seconds
- if (scrollStartDelay == 0)
- scrollStartDelay = now;
- if (!scrollStarted && now - scrollStartDelay > 2000)
- scrollStarted = true;
-
- if (totalHeight > usableHeight) {
- 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 {
- scrollY = 0;
- }
-
- int scrollOffset = static_cast(scrollY);
- int yOffset = -scrollOffset;
- if (!isInverted) {
- if (SCREEN_WIDTH > 128) {
- display->drawLine(0, yOffset + 20, SCREEN_WIDTH, yOffset + 20);
- } else {
- display->drawLine(0, yOffset + 14, SCREEN_WIDTH, yOffset + 14);
- }
- }
-
- // === Render visible lines ===
- for (size_t i = 0; i < lines.size(); ++i) {
- int lineY = yOffset;
- for (size_t j = 0; j < i; ++j)
- lineY += rowHeights[j];
- if (lineY > -rowHeights[i] && lineY < scrollBottom) {
- if (i == 0 && isInverted) {
- drawRoundedHighlight(display, x, lineY, SCREEN_WIDTH, FONT_HEIGHT_SMALL - 1, cornerRadius);
- display->setColor(BLACK);
- display->drawString(x + 3, lineY, lines[i].c_str());
- if (isBold)
- display->drawString(x + 4, lineY, lines[i].c_str());
- display->setColor(WHITE);
- } else {
- drawStringWithEmotes(display, x, lineY, lines[i], emotes, numEmotes);
- }
- }
- }
+ graphics::MessageRenderer::drawTextMessageFrame(display, state, x, y);
}
/// Draw a series of fields in a column, wrapping to multiple columns if needed
@@ -2156,611 +1938,6 @@ static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_
// Combined dynamic node list frame cycling through LastHeard, HopSignal, and Distance modes
// Uses a single frame and changes data every few seconds (E-Ink variant is separate)
-// =============================
-// Shared Types and Structures
-// =============================
-typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int);
-typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double);
-
-struct NodeEntry {
- meshtastic_NodeInfoLite *node;
- uint32_t lastHeard;
- float cachedDistance = -1.0f; // Only used in distance mode
-};
-
-// =============================
-// Shared Enums and Timing Logic
-// =============================
-enum NodeListMode { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_DISTANCE = 2, MODE_COUNT = 3 };
-
-static NodeListMode currentMode = MODE_LAST_HEARD;
-static int scrollIndex = 0;
-
-// Use dynamic timing based on mode
-unsigned long getModeCycleIntervalMs()
-{
- // return (currentMode == MODE_DISTANCE) ? 3000 : 2000;
- return 3000;
-}
-
-// h! Calculates bearing between two lat/lon points (used for compass)
-float calculateBearing(double lat1, double lon1, double lat2, double lon2)
-{
- double dLon = (lon2 - lon1) * DEG_TO_RAD;
- lat1 = lat1 * DEG_TO_RAD;
- lat2 = lat2 * DEG_TO_RAD;
-
- double y = sin(dLon) * cos(lat2);
- double x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon);
- double initialBearing = atan2(y, x);
-
- return fmod((initialBearing * RAD_TO_DEG + 360), 360); // Normalize to 0-360°
-}
-
-int calculateMaxScroll(int totalEntries, int visibleRows)
-{
- int totalRows = (totalEntries + 1) / 2;
- return std::max(0, totalRows - visibleRows);
-}
-
-// =============================
-// Node Sorting and Scroll Helpers
-// =============================
-String getSafeNodeName(meshtastic_NodeInfoLite *node)
-{
- String nodeName = "?";
- if (node->has_user && strlen(node->user.short_name) > 0) {
- bool valid = true;
- const char *name = node->user.short_name;
- for (size_t i = 0; i < strlen(name); i++) {
- uint8_t c = (uint8_t)name[i];
- if (c < 32 || c > 126) {
- valid = false;
- break;
- }
- }
- if (valid) {
- nodeName = name;
- } else {
- char idStr[6];
- snprintf(idStr, sizeof(idStr), "%04X", (uint16_t)(node->num & 0xFFFF));
- nodeName = String(idStr);
- }
- }
- return nodeName;
-}
-
-void retrieveAndSortNodes(std::vector &nodeList)
-{
- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
- bool hasValidSelf = nodeDB->hasValidPosition(ourNode);
-
- size_t numNodes = nodeDB->getNumMeshNodes();
- for (size_t i = 0; i < numNodes; i++) {
- meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
- if (!node || node->num == nodeDB->getNodeNum())
- continue;
-
- NodeEntry entry;
- entry.node = node;
- entry.lastHeard = sinceLastSeen(node);
- entry.cachedDistance = -1.0f;
-
- // Pre-calculate distance if we're about to render distance screen
- if (currentMode == MODE_DISTANCE && hasValidSelf && nodeDB->hasValidPosition(node)) {
- float lat1 = ourNode->position.latitude_i * 1e-7f;
- float lon1 = ourNode->position.longitude_i * 1e-7f;
- float lat2 = node->position.latitude_i * 1e-7f;
- float lon2 = node->position.longitude_i * 1e-7f;
-
- float dLat = (lat2 - lat1) * DEG_TO_RAD;
- float dLon = (lon2 - lon1) * DEG_TO_RAD;
- float a =
- sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
- float c = 2 * atan2(sqrt(a), sqrt(1 - a));
- entry.cachedDistance = 6371.0f * c; // Earth radius in km
- }
-
- nodeList.push_back(entry);
- }
-
- std::sort(nodeList.begin(), nodeList.end(), [](const NodeEntry &a, const NodeEntry &b) {
- bool aFav = a.node->is_favorite;
- bool bFav = b.node->is_favorite;
- if (aFav != bFav)
- return aFav > bFav;
- if (a.lastHeard == 0 || a.lastHeard == UINT32_MAX)
- return false;
- if (b.lastHeard == 0 || b.lastHeard == UINT32_MAX)
- return true;
- return a.lastHeard < b.lastHeard;
- });
-}
-
-void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
-{
- int columnWidth = display->getWidth() / 2;
- int separatorX = x + columnWidth - 2;
- display->drawLine(separatorX, yStart, separatorX, yEnd);
-}
-
-void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY)
-{
- const int rowHeight = FONT_HEIGHT_SMALL - 3;
- const int totalVisualRows = (totalEntries + columns - 1) / columns;
- if (totalVisualRows <= visibleNodeRows)
- return;
- const int scrollAreaHeight = visibleNodeRows * rowHeight;
- const int scrollbarX = display->getWidth() - 6;
- const int scrollbarWidth = 4;
- const int scrollBarHeight = (scrollAreaHeight * visibleNodeRows) / totalVisualRows;
- const int scrollBarY = scrollStartY + (scrollAreaHeight * scrollIndex) / totalVisualRows;
- display->drawRect(scrollbarX, scrollStartY, scrollbarWidth, scrollAreaHeight);
- display->fillRect(scrollbarX, scrollBarY, scrollbarWidth, scrollBarHeight);
-}
-
-// =============================
-// Shared Node List Screen Logic
-// =============================
-void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
- EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
- double lon = 0)
-{
- const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
- const int rowYOffset = FONT_HEIGHT_SMALL - 3;
-
- int columnWidth = display->getWidth() / 2;
-
- display->clear();
-
- // === Draw the battery/time header ===
- graphics::drawCommonHeader(display, x, y);
-
- // === Manually draw the centered title within the header ===
- const int highlightHeight = COMMON_HEADER_HEIGHT;
- const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
- const int centerX = x + SCREEN_WIDTH / 2;
-
- display->setFont(FONT_SMALL);
- display->setTextAlignment(TEXT_ALIGN_CENTER);
-
- if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
- display->setColor(BLACK);
-
- display->drawString(centerX, textY, title);
- if (config.display.heading_bold)
- display->drawString(centerX + 1, textY, title);
-
- display->setColor(WHITE);
- display->setTextAlignment(TEXT_ALIGN_LEFT);
-
- // === Space below header ===
- y += COMMON_HEADER_HEIGHT;
-
- // === Fetch and display sorted node list ===
- std::vector nodeList;
- retrieveAndSortNodes(nodeList);
-
- int totalEntries = nodeList.size();
- int totalRowsAvailable = (display->getHeight() - y) / rowYOffset;
-#ifdef USE_EINK
- totalRowsAvailable -= 1;
-#endif
- int visibleNodeRows = totalRowsAvailable;
- int totalColumns = 2;
-
- int startIndex = scrollIndex * visibleNodeRows * totalColumns;
- int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries);
-
- int yOffset = 0;
- int col = 0;
- int lastNodeY = y;
- int shownCount = 0;
- int rowCount = 0;
-
- for (int i = startIndex; i < endIndex; ++i) {
- int xPos = x + (col * columnWidth);
- int yPos = y + yOffset;
- renderer(display, nodeList[i].node, xPos, yPos, columnWidth);
-
- // ✅ Actually render the compass arrow
- if (extras) {
- extras(display, nodeList[i].node, xPos, yPos, columnWidth, heading, lat, lon);
- }
-
- lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
- yOffset += rowYOffset;
- shownCount++;
- rowCount++;
-
- if (rowCount >= totalRowsAvailable) {
- yOffset = 0;
- rowCount = 0;
- col++;
- if (col > (totalColumns - 1))
- break;
- }
- }
-
- // === Draw column separator
- if (shownCount > 0) {
- const int firstNodeY = y + 3;
- drawColumnSeparator(display, x, firstNodeY, lastNodeY);
- }
-
- const int scrollStartY = y + 3;
- drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY);
-}
-
-// =============================
-// Shared Dynamic Entry Renderers
-// =============================
-void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
-{
- bool isLeftCol = (x < SCREEN_WIDTH / 2);
- int timeOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
- : (isLeftCol ? 3 : 7); // Offset for Narrow Screens (Left Column:Right Column)
-
- String nodeName = getSafeNodeName(node);
-
- char timeStr[10];
- uint32_t seconds = sinceLastSeen(node);
- if (seconds == 0 || seconds == UINT32_MAX) {
- snprintf(timeStr, sizeof(timeStr), "?");
- } else {
- uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
- snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"),
- (days ? days
- : hours ? hours
- : minutes),
- (days ? 'd'
- : hours ? 'h'
- : 'm'));
- }
-
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
- display->drawString(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nodeName);
- if (node->is_favorite) {
- if (SCREEN_WIDTH > 128) {
- drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
- } else {
- display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
- }
- }
-
- int rightEdge = x + columnWidth - timeOffset;
- int textWidth = display->getStringWidth(timeStr);
- display->drawString(rightEdge - textWidth, y, timeStr);
-}
-
-// ****************************
-// * Hops / Signal Screen *
-// ****************************
-void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
-{
- bool isLeftCol = (x < SCREEN_WIDTH / 2);
-
- int nameMaxWidth = columnWidth - 25;
- int barsOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 16 : 20) // Offset for Wide Screens (Left Column:Right Column)
- : (isLeftCol ? 15 : 19); // Offset for Narrow Screens (Left Column:Right Column)
- int hopOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 22 : 28) // Offset for Wide Screens (Left Column:Right Column)
- : (isLeftCol ? 18 : 20); // Offset for Narrow Screens (Left Column:Right Column)
- int barsXOffset = columnWidth - barsOffset;
-
- String nodeName = getSafeNodeName(node);
-
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
-
- display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
- if (node->is_favorite) {
- if (SCREEN_WIDTH > 128) {
- drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
- } else {
- display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
- }
- }
-
- char hopStr[6] = "";
- if (node->has_hops_away && node->hops_away > 0)
- snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
-
- if (hopStr[0] != '\0') {
- int rightEdge = x + columnWidth - hopOffset;
- int textWidth = display->getStringWidth(hopStr);
- display->drawString(rightEdge - textWidth, y, hopStr);
- }
-
- int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
- int barWidth = 2;
- int barStartX = x + barsXOffset;
- int barStartY = y + (FONT_HEIGHT_SMALL / 2) + 2;
-
- for (int b = 0; b < 4; b++) {
- if (b < bars) {
- int height = (b * 2);
- display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
- }
- }
-}
-
-// **************************
-// * Distance Screen *
-// **************************
-void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
-{
- bool isLeftCol = (x < SCREEN_WIDTH / 2);
- int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
-
- String nodeName = getSafeNodeName(node);
- char distStr[10] = "";
-
- meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
- if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
- double lat1 = ourNode->position.latitude_i * 1e-7;
- double lon1 = ourNode->position.longitude_i * 1e-7;
- double lat2 = node->position.latitude_i * 1e-7;
- double lon2 = node->position.longitude_i * 1e-7;
-
- double earthRadiusKm = 6371.0;
- double dLat = (lat2 - lat1) * DEG_TO_RAD;
- double dLon = (lon2 - lon1) * DEG_TO_RAD;
-
- double a =
- sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
- double c = 2 * atan2(sqrt(a), sqrt(1 - a));
- double distanceKm = earthRadiusKm * c;
-
- if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
- double miles = distanceKm * 0.621371;
- if (miles < 0.1) {
- int feet = (int)(miles * 5280);
- if (feet < 1000)
- snprintf(distStr, sizeof(distStr), "%dft", feet);
- else
- snprintf(distStr, sizeof(distStr), "¼mi"); // 4-char max
- } else {
- int roundedMiles = (int)(miles + 0.5);
- if (roundedMiles < 1000)
- snprintf(distStr, sizeof(distStr), "%dmi", roundedMiles);
- else
- snprintf(distStr, sizeof(distStr), "999"); // Max display cap
- }
- } else {
- if (distanceKm < 1.0) {
- int meters = (int)(distanceKm * 1000);
- if (meters < 1000)
- snprintf(distStr, sizeof(distStr), "%dm", meters);
- else
- snprintf(distStr, sizeof(distStr), "1k");
- } else {
- int km = (int)(distanceKm + 0.5);
- if (km < 1000)
- snprintf(distStr, sizeof(distStr), "%dk", km);
- else
- snprintf(distStr, sizeof(distStr), "999");
- }
- }
- }
-
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
- display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
- if (node->is_favorite) {
- if (SCREEN_WIDTH > 128) {
- drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
- } else {
- display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
- }
- }
-
- if (strlen(distStr) > 0) {
- int offset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) // Offset for Wide Screens (Left Column:Right Column)
- : (isLeftCol ? 5 : 8); // Offset for Narrow Screens (Left Column:Right Column)
- int rightEdge = x + columnWidth - offset;
- int textWidth = display->getStringWidth(distStr);
- display->drawString(rightEdge - textWidth, y, distStr);
- }
-}
-
-// =============================
-// Dynamic Unified Entry Renderer
-// =============================
-void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
-{
- switch (currentMode) {
- case MODE_LAST_HEARD:
- drawEntryLastHeard(display, node, x, y, columnWidth);
- break;
- case MODE_HOP_SIGNAL:
- drawEntryHopSignal(display, node, x, y, columnWidth);
- break;
- case MODE_DISTANCE:
- drawNodeDistance(display, node, x, y, columnWidth);
- break;
- default:
- break; // Silences warning for MODE_COUNT or unexpected values
- }
-}
-
-const char *getCurrentModeTitle(int screenWidth)
-{
- switch (currentMode) {
- case MODE_LAST_HEARD:
- return "Node List";
- case MODE_HOP_SIGNAL:
- return (screenWidth > 128) ? "Hops|Signals" : "Hop|Sig";
- case MODE_DISTANCE:
- return "Distances";
- default:
- return "Nodes";
- }
-}
-
-// =============================
-// OLED/TFT Version (cycles every few seconds)
-// =============================
-#ifndef USE_EINK
-static void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- // Static variables to track mode and duration
- static NodeListMode lastRenderedMode = MODE_COUNT;
- static unsigned long modeStartTime = 0;
-
- unsigned long now = millis();
-
- // On very first call (on boot or state enter)
- if (lastRenderedMode == MODE_COUNT) {
- currentMode = MODE_LAST_HEARD;
- modeStartTime = now;
- }
-
- // Time to switch to next mode?
- if (now - modeStartTime >= getModeCycleIntervalMs()) {
- currentMode = static_cast((currentMode + 1) % MODE_COUNT);
- modeStartTime = now;
- }
-
- // Render screen based on currentMode
- const char *title = getCurrentModeTitle(display->getWidth());
- drawNodeListScreen(display, state, x, y, title, drawEntryDynamic);
-
- // Track the last mode to avoid reinitializing modeStartTime
- lastRenderedMode = currentMode;
-}
-#endif
-
-// =============================
-// E-Ink Version (mode set once per boot)
-// =============================
-#ifdef USE_EINK
-static void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- if (state->ticksSinceLastStateSwitch == 0) {
- currentMode = MODE_LAST_HEARD;
- }
- const char *title = getCurrentModeTitle(display->getWidth());
- drawNodeListScreen(display, state, x, y, title, drawEntryDynamic);
-}
-#endif
-
-// Add these below (still inside #ifdef USE_EINK if you prefer):
-#ifdef USE_EINK
-static void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- const char *title = "Node List";
- drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard);
-}
-
-static void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- const char *title = (display->getWidth() > 128) ? "Hops|Signals" : "Hop|Sig";
- drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal);
-}
-
-static void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- const char *title = "Distances";
- drawNodeListScreen(display, state, x, y, title, drawNodeDistance);
-}
-#endif
-// Helper function: Draw a single node entry for Node List (Modified for Compass Screen)
-void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
-{
- bool isLeftCol = (x < SCREEN_WIDTH / 2);
-
- // Adjust max text width depending on column and screen width
- int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
-
- String nodeName = getSafeNodeName(node);
-
- display->setTextAlignment(TEXT_ALIGN_LEFT);
- display->setFont(FONT_SMALL);
- display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
- if (node->is_favorite) {
- if (SCREEN_WIDTH > 128) {
- drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
- } else {
- display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
- }
- }
-}
-void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
- double userLat, double userLon)
-{
- if (!nodeDB->hasValidPosition(node))
- return;
-
- bool isLeftCol = (x < SCREEN_WIDTH / 2);
- int arrowXOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18);
-
- int centerX = x + columnWidth - arrowXOffset;
- int centerY = y + FONT_HEIGHT_SMALL / 2;
-
- double nodeLat = node->position.latitude_i * 1e-7;
- double nodeLon = node->position.longitude_i * 1e-7;
- float bearingToNode = calculateBearing(userLat, userLon, nodeLat, nodeLon);
- float relativeBearing = fmod((bearingToNode - myHeading + 360), 360);
- float angle = relativeBearing * DEG_TO_RAD;
-
- // Shrink size by 2px
- int size = FONT_HEIGHT_SMALL - 5;
- float halfSize = size / 2.0;
-
- // Point of the arrow
- int tipX = centerX + halfSize * cos(angle);
- int tipY = centerY - halfSize * sin(angle);
-
- float baseAngle = radians(35);
- float sideLen = halfSize * 0.95;
- float notchInset = halfSize * 0.35;
-
- // Left and right corners
- int leftX = centerX + sideLen * cos(angle + PI - baseAngle);
- int leftY = centerY - sideLen * sin(angle + PI - baseAngle);
-
- int rightX = centerX + sideLen * cos(angle + PI + baseAngle);
- int rightY = centerY - sideLen * sin(angle + PI + baseAngle);
-
- // Center notch (cut-in)
- int notchX = centerX - notchInset * cos(angle);
- int notchY = centerY + notchInset * sin(angle);
-
- // Draw the chevron-style arrowhead
- display->fillTriangle(tipX, tipY, leftX, leftY, notchX, notchY);
- display->fillTriangle(tipX, tipY, notchX, notchY, rightX, rightY);
-}
-
-// Public screen entry for compass
-static void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
-{
- float heading = 0;
- bool validHeading = false;
- double lat = 0;
- double lon = 0;
-
-#if HAS_GPS
- geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
- int32_t(gpsStatus->getAltitude()));
- lat = geoCoord.getLatitude() * 1e-7;
- lon = geoCoord.getLongitude() * 1e-7;
-
- if (screen->hasHeading()) {
- heading = screen->getHeading(); // degrees
- validHeading = true;
- } else {
- heading = screen->estimatedHeading(lat, lon);
- validHeading = !isnan(heading);
- }
-#endif
-
- if (!validHeading)
- return;
-
- drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
-}
-
// ****************************
// * Device Focused Screen *
// ****************************
@@ -4461,7 +3638,7 @@ void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
if (!Throttle::isWithinTimespanMs(storeForwardModule->lastHeartbeat,
(storeForwardModule->heartbeatInterval * 1200))) { // no heartbeat, overlap a bit
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
- defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \
+ defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || ARCH_PORTDUINO) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8,
imgQuestionL1);
@@ -4488,7 +3665,7 @@ void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
} else {
// TODO: Raspberry Pi supports more than just the one screen size
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
- defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || ARCH_PORTDUINO) && \
+ defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || ARCH_PORTDUINO) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8,
imgInfoL1);
diff --git a/src/graphics/draw/ClockRenderer.h b/src/graphics/draw/ClockRenderer.h
new file mode 100644
index 000000000..23ad79d39
--- /dev/null
+++ b/src/graphics/draw/ClockRenderer.h
@@ -0,0 +1,40 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Clock drawing functions
+ *
+ * Contains all functions related to drawing analog and digital clocks,
+ * segmented displays, and time-related UI elements.
+ */
+namespace ClockRenderer
+{
+// Clock frame functions
+void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Segmented display functions
+void drawSegmentedDisplayCharacter(OLEDDisplay *display, int x, int y, uint8_t number, float scale = 1);
+void drawSegmentedDisplayColon(OLEDDisplay *display, int x, int y, float scale = 1);
+void drawHorizontalSegment(OLEDDisplay *display, int x, int y, int width, int height);
+void drawVerticalSegment(OLEDDisplay *display, int x, int y, int width, int height);
+
+// UI elements for clock displays
+void drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode = true, float scale = 1);
+void drawBluetoothConnectedIcon(OLEDDisplay *display, int16_t x, int16_t y);
+
+// Utility functions
+bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo);
+
+} // namespace ClockRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp
new file mode 100644
index 000000000..da763b64d
--- /dev/null
+++ b/src/graphics/draw/CompassRenderer.cpp
@@ -0,0 +1,131 @@
+#include "CompassRenderer.h"
+#include "UIRenderer.h"
+#include "configuration.h"
+#include "gps/GeoCoord.h"
+#include "graphics/ScreenFonts.h"
+#include
+
+namespace graphics
+{
+namespace CompassRenderer
+{
+
+// Point helper class for compass calculations
+struct Point {
+ float x, y;
+ Point(float x, float y) : x(x), y(y) {}
+
+ void rotate(float angle)
+ {
+ float cos_a = cos(angle);
+ float sin_a = sin(angle);
+ float new_x = x * cos_a - y * sin_a;
+ float new_y = x * sin_a + y * cos_a;
+ x = new_x;
+ y = new_y;
+ }
+
+ void scale(float factor)
+ {
+ x *= factor;
+ y *= factor;
+ }
+
+ void translate(float dx, float dy)
+ {
+ x += dx;
+ y += dy;
+ }
+};
+
+void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading)
+{
+ // Show the compass heading (not implemented in original)
+ // This could draw a "N" indicator or north arrow
+ // For now, we'll draw a simple north indicator
+ const float radius = 8.0f;
+ Point north(0, -radius);
+ north.rotate(-myHeading);
+ north.translate(compassX, compassY);
+
+ // Draw a small "N" or north indicator
+ display->drawCircle(north.x, north.y, 2);
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->drawString(north.x, north.y - 3, "N");
+}
+
+void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
+{
+ Point tip(0.0f, 0.5f), tail(0.0f, -0.35f); // pointing up initially
+ float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f;
+ Point leftArrow(tip.x - arrowOffsetX, tip.y - arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y - arrowOffsetY);
+
+ Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
+
+ for (int i = 0; i < 4; i++) {
+ arrowPoints[i]->rotate(headingRadian);
+ arrowPoints[i]->scale(compassDiam * 0.6);
+ arrowPoints[i]->translate(compassX, compassY);
+ }
+
+#ifdef USE_EINK
+ display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
+#else
+ display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
+#endif
+ display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
+}
+
+void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
+{
+ float radians = bearing * DEG_TO_RAD;
+
+ Point tip(0, -size / 2);
+ Point left(-size / 4, size / 4);
+ Point right(size / 4, size / 4);
+
+ tip.rotate(radians);
+ left.rotate(radians);
+ right.rotate(radians);
+
+ tip.translate(x, y);
+ left.translate(x, y);
+ right.translate(x, y);
+
+ display->drawTriangle(tip.x, tip.y, left.x, left.y, right.x, right.y);
+}
+
+float estimatedHeading(double lat, double lon)
+{
+ // Simple magnetic declination estimation
+ // This is a very basic implementation - the original might be more sophisticated
+ return 0.0f; // Return 0 for now, indicating no heading available
+}
+
+uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight)
+{
+ // Calculate appropriate compass diameter based on display size
+ uint16_t minDimension = (displayWidth < displayHeight) ? displayWidth : displayHeight;
+ uint16_t maxDiam = minDimension / 3; // Use 1/3 of the smaller dimension
+
+ // Ensure minimum and maximum bounds
+ if (maxDiam < 16)
+ maxDiam = 16;
+ if (maxDiam > 64)
+ maxDiam = 64;
+
+ return maxDiam;
+}
+
+float calculateBearing(double lat1, double lon1, double lat2, double lon2)
+{
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+ double y = sin(dLon) * cos(lat2 * DEG_TO_RAD);
+ double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon);
+ double bearing = atan2(y, x) * RAD_TO_DEG;
+ return fmod(bearing + 360.0, 360.0);
+}
+
+} // namespace CompassRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h
new file mode 100644
index 000000000..2f7197084
--- /dev/null
+++ b/src/graphics/draw/CompassRenderer.h
@@ -0,0 +1,36 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Compass and navigation drawing functions
+ *
+ * Contains all functions related to drawing compass elements, headings,
+ * navigation arrows, and location-based UI components.
+ */
+namespace CompassRenderer
+{
+// Compass drawing functions
+void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading);
+void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian);
+void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing);
+
+// Navigation and location functions
+float estimatedHeading(double lat, double lon);
+uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight);
+
+// Utility functions for bearing calculations
+float calculateBearing(double lat1, double lon1, double lat2, double lon2);
+
+} // namespace CompassRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h
new file mode 100644
index 000000000..d55f35458
--- /dev/null
+++ b/src/graphics/draw/DebugRenderer.h
@@ -0,0 +1,34 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+class DebugInfo;
+
+/**
+ * @brief Debug and diagnostic drawing functions
+ *
+ * Contains all functions related to drawing debug information,
+ * WiFi status, settings screens, and diagnostic data.
+ */
+namespace DebugRenderer
+{
+// Debug frame functions (friend functions for DebugInfo class)
+void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Trampoline functions for DebugInfo class access
+void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+} // namespace DebugRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/DrawRenderers.h b/src/graphics/draw/DrawRenderers.h
new file mode 100644
index 000000000..6f1929ebd
--- /dev/null
+++ b/src/graphics/draw/DrawRenderers.h
@@ -0,0 +1,38 @@
+#pragma once
+
+/**
+ * @brief Master include file for all Screen draw renderers
+ *
+ * This file includes all the individual renderer headers to provide
+ * a convenient single include for accessing all draw functions.
+ */
+
+#include "graphics/draw/ClockRenderer.h"
+#include "graphics/draw/CompassRenderer.h"
+#include "graphics/draw/DebugRenderer.h"
+#include "graphics/draw/NodeListRenderer.h"
+#include "graphics/draw/ScreenRenderer.h"
+#include "graphics/draw/UIRenderer.h"
+
+namespace graphics
+{
+
+/**
+ * @brief Collection of all draw renderers
+ *
+ * This namespace provides access to all the specialized rendering
+ * functions organized by category.
+ */
+namespace DrawRenderers
+{
+// Re-export all renderer namespaces for convenience
+using namespace ClockRenderer;
+using namespace CompassRenderer;
+using namespace DebugRenderer;
+using namespace NodeListRenderer;
+using namespace ScreenRenderer;
+using namespace UIRenderer;
+
+} // namespace DrawRenderers
+
+} // namespace graphics
diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp
new file mode 100644
index 000000000..7ece6542d
--- /dev/null
+++ b/src/graphics/draw/MessageRenderer.cpp
@@ -0,0 +1,367 @@
+/*
+BaseUI
+
+Developed and Maintained By:
+- Ronald Garcia (HarukiToreda) – Lead development and implementation.
+- JasonP (Xaositek) – Screen layout and icon design, UI improvements and testing.
+- TonyG (Tropho) – Project management, structural planning, and testing
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+
+*/
+
+#include "MessageRenderer.h"
+
+// Core includes
+#include "NodeDB.h"
+#include "configuration.h"
+#include "gps/RTC.h"
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/emotes.h"
+#include "main.h"
+#include "meshUtils.h"
+
+// Additional includes for UI rendering
+#include "UIRenderer.h"
+
+// Additional includes for dependencies
+#include
+#include
+
+// External declarations
+extern bool hasUnreadMessage;
+extern meshtastic_DeviceState devicestate;
+
+using graphics::Emote;
+using graphics::emotes;
+using graphics::numEmotes;
+
+namespace graphics
+{
+namespace MessageRenderer
+{
+
+// Forward declaration from Screen.cpp - this function needs to be accessible
+// For now, we'll implement a local version that matches the Screen.cpp functionality
+bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int32_t *daysAgo)
+{
+ // Cache the result - avoid frequent recalculation
+ static uint8_t hoursCached = 0, minutesCached = 0;
+ static uint32_t daysAgoCached = 0;
+ static uint32_t secondsAgoCached = 0;
+ static bool validCached = false;
+
+ // Abort: if timezone not set
+ if (strlen(config.device.tzdef) == 0) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Abort: if invalid pointers passed
+ if (hours == nullptr || minutes == nullptr || daysAgo == nullptr) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Abort: if time seems invalid.. (> 6 months ago, probably seen before RTC set)
+ if (secondsAgo > SEC_PER_DAY * 30UL * 6) {
+ validCached = false;
+ return validCached;
+ }
+
+ // If repeated request, don't bother recalculating
+ if (secondsAgo - secondsAgoCached < 60 && secondsAgoCached != 0) {
+ if (validCached) {
+ *hours = hoursCached;
+ *minutes = minutesCached;
+ *daysAgo = daysAgoCached;
+ }
+ return validCached;
+ }
+
+ // Get local time
+ uint32_t secondsRTC = getValidTime(RTCQuality::RTCQualityDevice, true); // Get local time
+
+ // Abort: if RTC not set
+ if (!secondsRTC) {
+ validCached = false;
+ return validCached;
+ }
+
+ // Get absolute time when last seen
+ uint32_t secondsSeenAt = secondsRTC - secondsAgo;
+
+ // Calculate daysAgo
+ *daysAgo = (secondsRTC / SEC_PER_DAY) - (secondsSeenAt / SEC_PER_DAY); // How many "midnights" have passed
+
+ // Get seconds since midnight
+ uint32_t hms = (secondsRTC - secondsAgo) % SEC_PER_DAY;
+ hms = (hms + SEC_PER_DAY) % SEC_PER_DAY;
+
+ // Tear apart hms into hours and minutes
+ *hours = hms / SEC_PER_HOUR;
+ *minutes = (hms % SEC_PER_HOUR) / SEC_PER_MIN;
+
+ // Cache the result
+ daysAgoCached = *daysAgo;
+ hoursCached = *hours;
+ minutesCached = *minutes;
+ secondsAgoCached = secondsAgo;
+
+ validCached = true;
+ return validCached;
+}
+
+// Forward declaration for drawTimeDelta - we need access to this Screen method
+// For now, we'll implement a local version
+std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds)
+{
+ const uint32_t hours_in_month = 730;
+ std::string uptime;
+
+ if (days > (hours_in_month * 6))
+ uptime = "?";
+ else if (days >= 2)
+ uptime = std::to_string(days) + "d";
+ else if (hours >= 2)
+ uptime = std::to_string(hours) + "h";
+ else if (minutes >= 1)
+ uptime = std::to_string(minutes) + "m";
+ else
+ uptime = std::to_string(seconds) + "s";
+ return uptime;
+}
+
+void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // Clear the unread message indicator when viewing the message
+ hasUnreadMessage = false;
+
+ const meshtastic_MeshPacket &mp = devicestate.rx_text_message;
+ const char *msg = reinterpret_cast(mp.decoded.payload.bytes);
+
+ 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;
+ const int textWidth = SCREEN_WIDTH;
+ const int cornerRadius = 2;
+
+ bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED);
+ bool isBold = config.display.heading_bold;
+
+ // === Header Construction ===
+ meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
+ char headerStr[80];
+ const char *sender = "???";
+ if (node && node->has_user) {
+ if (SCREEN_WIDTH >= 200 && strlen(node->user.long_name) > 0) {
+ sender = node->user.long_name;
+ } else {
+ sender = node->user.short_name;
+ }
+ }
+ uint32_t seconds = sinceReceived(&mp), minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
+ uint8_t timestampHours, timestampMinutes;
+ int32_t daysAgo;
+ bool useTimestamp = deltaToTimestamp(seconds, ×tampHours, ×tampMinutes, &daysAgo);
+
+ if (useTimestamp && minutes >= 15 && daysAgo == 0) {
+ std::string prefix = (daysAgo == 1 && SCREEN_WIDTH >= 200) ? "Yesterday" : "At";
+ if (config.display.use_12h_clock) {
+ bool isPM = timestampHours >= 12;
+ timestampHours = timestampHours % 12;
+ if (timestampHours == 0)
+ timestampHours = 12;
+ snprintf(headerStr, sizeof(headerStr), "%s %d:%02d%s from %s", prefix.c_str(), timestampHours, timestampMinutes,
+ isPM ? "p" : "a", sender);
+ } else {
+ snprintf(headerStr, sizeof(headerStr), "%s %d:%02d from %s", prefix.c_str(), timestampHours, timestampMinutes,
+ sender);
+ }
+ } else {
+ snprintf(headerStr, sizeof(headerStr), "%s ago from %s", drawTimeDelta(days, hours, minutes, seconds).c_str(), sender);
+ }
+
+#ifndef EXCLUDE_EMOJI
+ // === Bounce animation setup ===
+ static uint32_t lastBounceTime = 0;
+ static int bounceY = 0;
+ const int bounceRange = 2; // Max pixels to bounce up/down
+ const int bounceInterval = 60; // How quickly to change bounce direction (ms)
+
+ uint32_t now = millis();
+ if (now - lastBounceTime >= bounceInterval) {
+ lastBounceTime = now;
+ bounceY = (bounceY + 1) % (bounceRange * 2);
+ }
+ for (int i = 0; i < numEmotes; ++i) {
+ const Emote &e = emotes[i];
+ if (strcmp(msg, e.label) == 0) {
+ // Draw the header
+ if (isInverted) {
+ drawRoundedHighlight(display, x, 0, SCREEN_WIDTH, FONT_HEIGHT_SMALL - 1, cornerRadius);
+ display->setColor(BLACK);
+ display->drawString(x + 3, 0, headerStr);
+ if (isBold)
+ display->drawString(x + 4, 0, headerStr);
+ display->setColor(WHITE);
+ } else {
+ display->drawString(x, 0, headerStr);
+ if (SCREEN_WIDTH > 128) {
+ display->drawLine(0, 20, SCREEN_WIDTH, 20);
+ } else {
+ display->drawLine(0, 14, SCREEN_WIDTH, 14);
+ }
+ }
+
+ // Center the emote below header + apply bounce
+ int remainingHeight = SCREEN_HEIGHT - FONT_HEIGHT_SMALL - navHeight;
+ int emoteY = FONT_HEIGHT_SMALL + (remainingHeight - e.height) / 2 + bounceY - bounceRange;
+ display->drawXbm((SCREEN_WIDTH - e.width) / 2, emoteY, e.width, e.height, e.bitmap);
+ return;
+ }
+ }
+#endif
+
+ // === Word-wrap and build line list ===
+ char messageBuf[237];
+ snprintf(messageBuf, sizeof(messageBuf), "%s", msg);
+
+ std::vector lines;
+ lines.push_back(std::string(headerStr)); // Header line is always first
+
+ std::string line, word;
+ for (int i = 0; messageBuf[i]; ++i) {
+ char ch = messageBuf[i];
+ 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;
+ if (display->getStringWidth(test.c_str()) > textWidth + 4) {
+ if (!line.empty())
+ lines.push_back(line);
+ line = word;
+ word.clear();
+ }
+ }
+ }
+ if (!word.empty())
+ line += word;
+ if (!line.empty())
+ lines.push_back(line);
+
+ // === Scrolling logic ===
+ std::vector rowHeights;
+
+ for (const auto &line : lines) {
+ int maxHeight = FONT_HEIGHT_SMALL;
+ for (int i = 0; i < numEmotes; ++i) {
+ const Emote &e = emotes[i];
+ if (line.find(e.label) != std::string::npos) {
+ if (e.height > maxHeight)
+ maxHeight = e.height;
+ }
+ }
+ rowHeights.push_back(maxHeight);
+ }
+ int totalHeight = 0;
+ for (size_t i = 1; i < rowHeights.size(); ++i) {
+ totalHeight += rowHeights[i];
+ }
+ int usableScrollHeight = usableHeight - rowHeights[0]; // remove header height
+ int scrollStop = std::max(0, totalHeight - usableScrollHeight);
+
+ static float scrollY = 0.0f;
+ static uint32_t lastTime = 0, scrollStartDelay = 0, pauseStart = 0;
+ static bool waitingToReset = false, scrollStarted = false;
+
+ // === Smooth scrolling adjustment ===
+ // You can tweak this divisor to change how smooth it scrolls.
+ // Lower = smoother, but can feel slow.
+ float delta = (now - lastTime) / 400.0f;
+ lastTime = now;
+
+ const float scrollSpeed = 2.0f; // pixels per second
+
+ // Delay scrolling start by 2 seconds
+ if (scrollStartDelay == 0)
+ scrollStartDelay = now;
+ if (!scrollStarted && now - scrollStartDelay > 2000)
+ scrollStarted = true;
+
+ if (totalHeight > usableHeight) {
+ 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 {
+ scrollY = 0;
+ }
+
+ int scrollOffset = static_cast(scrollY);
+ int yOffset = -scrollOffset;
+ if (!isInverted) {
+ if (SCREEN_WIDTH > 128) {
+ display->drawLine(0, yOffset + 20, SCREEN_WIDTH, yOffset + 20);
+ } else {
+ display->drawLine(0, yOffset + 14, SCREEN_WIDTH, yOffset + 14);
+ }
+ }
+
+ // === Render visible lines ===
+ for (size_t i = 0; i < lines.size(); ++i) {
+ int lineY = yOffset;
+ for (size_t j = 0; j < i; ++j)
+ lineY += rowHeights[j];
+ if (lineY > -rowHeights[i] && lineY < scrollBottom) {
+ if (i == 0 && isInverted) {
+ drawRoundedHighlight(display, x, lineY, SCREEN_WIDTH, FONT_HEIGHT_SMALL - 1, cornerRadius);
+ display->setColor(BLACK);
+ display->drawString(x + 3, lineY, lines[i].c_str());
+ if (isBold)
+ display->drawString(x + 4, lineY, lines[i].c_str());
+ display->setColor(WHITE);
+ } else {
+ graphics::UIRenderer::drawStringWithEmotes(display, x, lineY, lines[i], emotes, numEmotes);
+ }
+ }
+ }
+}
+
+} // namespace MessageRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h
new file mode 100644
index 000000000..36bfa4ccf
--- /dev/null
+++ b/src/graphics/draw/MessageRenderer.h
@@ -0,0 +1,14 @@
+#pragma once
+#include "OLEDDisplay.h"
+#include "OLEDDisplayUi.h"
+
+namespace graphics
+{
+namespace MessageRenderer
+{
+
+/// Draws the text message frame for displaying received messages
+void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+} // namespace MessageRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp
new file mode 100644
index 000000000..35c6c032d
--- /dev/null
+++ b/src/graphics/draw/NodeListRenderer.cpp
@@ -0,0 +1,811 @@
+#include "NodeListRenderer.h"
+#include "CompassRenderer.h"
+#include "NodeDB.h"
+#include "UIRenderer.h"
+#include "configuration.h"
+#include "gps/GeoCoord.h"
+#include "gps/RTC.h" // for getTime() function
+#include "graphics/ScreenFonts.h"
+#include "graphics/SharedUIDisplay.h"
+#include "graphics/images.h"
+#include
+
+// Forward declarations for functions defined in Screen.cpp
+namespace graphics
+{
+extern bool haveGlyphs(const char *str);
+} // namespace graphics
+
+// Global screen instance
+extern graphics::Screen *screen;
+
+namespace graphics
+{
+namespace NodeListRenderer
+{
+
+// Function moved from Screen.cpp to NodeListRenderer.cpp since it's primarily used here
+void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display)
+{
+ for (int row = 0; row < height; row++) {
+ uint8_t rowMask = (1 << row);
+ for (int col = 0; col < width; col++) {
+ uint8_t colData = pgm_read_byte(&bitmapXBM[col]);
+ if (colData & rowMask) {
+ // Note: rows become X, columns become Y after transpose
+ display->fillRect(x + row * 2, y + col * 2, 2, 2);
+ }
+ }
+ }
+}
+
+// Static variables for dynamic cycling
+static NodeListMode currentMode = MODE_LAST_HEARD;
+static int scrollIndex = 0;
+
+// =============================
+// Utility Functions
+// =============================
+
+String getSafeNodeName(meshtastic_NodeInfoLite *node)
+{
+ String nodeName = "?";
+ if (node->has_user && strlen(node->user.short_name) > 0) {
+ bool valid = true;
+ const char *name = node->user.short_name;
+ for (size_t i = 0; i < strlen(name); i++) {
+ uint8_t c = (uint8_t)name[i];
+ if (c < 32 || c > 126) {
+ valid = false;
+ break;
+ }
+ }
+ if (valid) {
+ nodeName = name;
+ } else {
+ char idStr[6];
+ snprintf(idStr, sizeof(idStr), "%04X", (uint16_t)(node->num & 0xFFFF));
+ nodeName = String(idStr);
+ }
+ }
+ return nodeName;
+}
+
+uint32_t sinceLastSeen(meshtastic_NodeInfoLite *node)
+{
+ uint32_t now = getTime();
+ uint32_t last_seen = node->last_heard;
+ if (last_seen == 0 || now < last_seen) {
+ return UINT32_MAX;
+ }
+ return now - last_seen;
+}
+
+const char *getCurrentModeTitle(int screenWidth)
+{
+ switch (currentMode) {
+ case MODE_LAST_HEARD:
+ return "Node List";
+ case MODE_HOP_SIGNAL:
+ return (screenWidth > 128) ? "Hops/Signal" : "Hops/Sig";
+ case MODE_DISTANCE:
+ return "Distance";
+ default:
+ return "Nodes";
+ }
+}
+
+// Use dynamic timing based on mode
+unsigned long getModeCycleIntervalMs()
+{
+ return 3000;
+}
+
+// Calculate bearing between two lat/lon points
+float calculateBearing(double lat1, double lon1, double lat2, double lon2)
+{
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+ double y = sin(dLon) * cos(lat2 * DEG_TO_RAD);
+ double x = cos(lat1 * DEG_TO_RAD) * sin(lat2 * DEG_TO_RAD) - sin(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * cos(dLon);
+ double bearing = atan2(y, x) * RAD_TO_DEG;
+ return fmod(bearing + 360.0, 360.0);
+}
+
+int calculateMaxScroll(int totalEntries, int visibleRows)
+{
+ return std::max(0, (totalEntries - 1) / (visibleRows * 2));
+}
+
+void retrieveAndSortNodes(std::vector &nodeList)
+{
+ size_t numNodes = nodeDB->getNumMeshNodes();
+ for (size_t i = 0; i < numNodes; i++) {
+ meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
+ if (!node || node->num == nodeDB->getNodeNum())
+ continue;
+
+ NodeEntry entry;
+ entry.node = node;
+ entry.sortValue = sinceLastSeen(node);
+
+ nodeList.push_back(entry);
+ }
+
+ // Sort nodes: favorites first, then by last heard (most recent first)
+ std::sort(nodeList.begin(), nodeList.end(), [](const NodeEntry &a, const NodeEntry &b) {
+ bool aFav = a.node->is_favorite;
+ bool bFav = b.node->is_favorite;
+ if (aFav != bFav)
+ return aFav > bFav;
+ if (a.sortValue == 0 || a.sortValue == UINT32_MAX)
+ return false;
+ if (b.sortValue == 0 || b.sortValue == UINT32_MAX)
+ return true;
+ return a.sortValue < b.sortValue;
+ });
+}
+
+void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
+{
+ int columnWidth = display->getWidth() / 2;
+ int separatorX = x + columnWidth - 1;
+ for (int y = yStart; y <= yEnd; y += 2) {
+ display->setPixel(separatorX, y);
+ }
+}
+
+void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries, int scrollIndex, int columns, int scrollStartY)
+{
+ if (totalEntries <= visibleNodeRows * columns)
+ return;
+
+ int scrollbarX = display->getWidth() - 2;
+ int scrollbarHeight = display->getHeight() - scrollStartY - 10;
+ int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries);
+ int maxScroll = calculateMaxScroll(totalEntries, visibleNodeRows);
+ int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll);
+
+ for (int i = 0; i < thumbHeight; i++) {
+ display->setPixel(scrollbarX, thumbY + i);
+ }
+}
+
+// =============================
+// Entry Renderers
+// =============================
+
+void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int timeOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
+
+ String nodeName = getSafeNodeName(node);
+
+ char timeStr[10];
+ uint32_t seconds = sinceLastSeen(node);
+ if (seconds == 0 || seconds == UINT32_MAX) {
+ snprintf(timeStr, sizeof(timeStr), "?");
+ } else {
+ uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
+ snprintf(timeStr, sizeof(timeStr), (days > 365 ? "?" : "%d%c"),
+ (days ? days
+ : hours ? hours
+ : minutes),
+ (days ? 'd'
+ : hours ? 'h'
+ : 'm'));
+ }
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawString(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ int rightEdge = x + columnWidth - timeOffset;
+ int textWidth = display->getStringWidth(timeStr);
+ display->drawString(rightEdge - textWidth, y, timeStr);
+}
+
+void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+
+ int nameMaxWidth = columnWidth - 25;
+ int barsOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 16 : 20) : (isLeftCol ? 15 : 19);
+ int hopOffset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 22 : 28) : (isLeftCol ? 18 : 20);
+
+ int barsXOffset = columnWidth - barsOffset;
+
+ String nodeName = getSafeNodeName(node);
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ // Draw signal strength bars
+ int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0;
+ int barWidth = 2;
+ int barStartX = x + barsXOffset;
+ int barStartY = y + (FONT_HEIGHT_SMALL / 2) + 2;
+
+ for (int b = 0; b < 4; b++) {
+ if (b < bars) {
+ int height = (b * 2);
+ display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height);
+ }
+ }
+
+ // Draw hop count
+ char hopStr[6] = "";
+ if (node->has_hops_away && node->hops_away > 0)
+ snprintf(hopStr, sizeof(hopStr), "[%d]", node->hops_away);
+
+ if (hopStr[0] != '\0') {
+ int rightEdge = x + columnWidth - hopOffset;
+ int textWidth = display->getStringWidth(hopStr);
+ display->drawString(rightEdge - textWidth, y, hopStr);
+ }
+}
+
+void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+
+ String nodeName = getSafeNodeName(node);
+ char distStr[10] = "";
+
+ meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
+ if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
+ double lat1 = ourNode->position.latitude_i * 1e-7;
+ double lon1 = ourNode->position.longitude_i * 1e-7;
+ double lat2 = node->position.latitude_i * 1e-7;
+ double lon2 = node->position.longitude_i * 1e-7;
+
+ double earthRadiusKm = 6371.0;
+ double dLat = (lat2 - lat1) * DEG_TO_RAD;
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+
+ double a =
+ sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
+ double c = 2 * atan2(sqrt(a), sqrt(1 - a));
+ double distanceKm = earthRadiusKm * c;
+
+ if (distanceKm < 1.0) {
+ int meters = (int)(distanceKm * 1000);
+ if (meters < 1000) {
+ snprintf(distStr, sizeof(distStr), "%dm", meters);
+ } else {
+ snprintf(distStr, sizeof(distStr), "1km");
+ }
+ } else {
+ int km = (int)(distanceKm + 0.5);
+ if (km < 1000) {
+ snprintf(distStr, sizeof(distStr), "%dkm", km);
+ } else {
+ snprintf(distStr, sizeof(distStr), "999");
+ }
+ }
+ }
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+
+ if (strlen(distStr) > 0) {
+ int offset = (SCREEN_WIDTH > 128) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 5 : 8);
+ int rightEdge = x + columnWidth - offset;
+ int textWidth = display->getStringWidth(distStr);
+ display->drawString(rightEdge - textWidth, y, distStr);
+ }
+}
+
+void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ switch (currentMode) {
+ case MODE_LAST_HEARD:
+ drawEntryLastHeard(display, node, x, y, columnWidth);
+ break;
+ case MODE_HOP_SIGNAL:
+ drawEntryHopSignal(display, node, x, y, columnWidth);
+ break;
+ case MODE_DISTANCE:
+ drawNodeDistance(display, node, x, y, columnWidth);
+ break;
+ default:
+ break;
+ }
+}
+
+void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
+{
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int nameMaxWidth = columnWidth - (SCREEN_WIDTH > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
+
+ String nodeName = getSafeNodeName(node);
+
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+ display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 2), y, nameMaxWidth, nodeName);
+ if (node->is_favorite) {
+ if (SCREEN_WIDTH > 128) {
+ drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
+ } else {
+ display->drawXbm(x, y + 5, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint);
+ }
+ }
+}
+
+void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
+ double userLat, double userLon)
+{
+ if (!nodeDB->hasValidPosition(node))
+ return;
+
+ double nodeLat = node->position.latitude_i * 1e-7;
+ double nodeLon = node->position.longitude_i * 1e-7;
+ float bearing = calculateBearing(userLat, userLon, nodeLat, nodeLon);
+
+ if (!config.display.compass_north_top)
+ bearing -= myHeading;
+
+ bool isLeftCol = (x < SCREEN_WIDTH / 2);
+ int arrowSize = 6;
+ int arrowX = x + columnWidth - (isLeftCol ? 12 : 16);
+ int arrowY = y + FONT_HEIGHT_SMALL / 2;
+
+ CompassRenderer::drawArrowToNode(display, arrowX, arrowY, arrowSize, bearing);
+}
+
+// =============================
+// Main Screen Functions
+// =============================
+
+void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
+ EntryRenderer renderer, NodeExtrasRenderer extras, float heading, double lat, double lon)
+{
+ const int COMMON_HEADER_HEIGHT = FONT_HEIGHT_SMALL - 1;
+ const int rowYOffset = FONT_HEIGHT_SMALL - 3;
+
+ int columnWidth = display->getWidth() / 2;
+
+ display->clear();
+
+ // Draw the battery/time header
+ graphics::drawCommonHeader(display, x, y);
+
+ // Draw the centered title within the header
+ const int highlightHeight = COMMON_HEADER_HEIGHT;
+ const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
+ const int centerX = x + SCREEN_WIDTH / 2;
+
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
+ display->setColor(BLACK);
+
+ display->drawString(centerX, textY, title);
+ if (config.display.heading_bold)
+ display->drawString(centerX + 1, textY, title);
+
+ display->setColor(WHITE);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+
+ // Space below header
+ y += COMMON_HEADER_HEIGHT;
+
+ // Fetch and display sorted node list
+ std::vector nodeList;
+ retrieveAndSortNodes(nodeList);
+
+ int totalEntries = nodeList.size();
+ int totalRowsAvailable = (display->getHeight() - y) / rowYOffset;
+#ifdef USE_EINK
+ totalRowsAvailable -= 1;
+#endif
+ int visibleNodeRows = totalRowsAvailable;
+ int totalColumns = 2;
+
+ int startIndex = scrollIndex * visibleNodeRows * totalColumns;
+ int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries);
+
+ int yOffset = 0;
+ int col = 0;
+ int lastNodeY = y;
+ int shownCount = 0;
+ int rowCount = 0;
+
+ for (int i = startIndex; i < endIndex; ++i) {
+ int xPos = x + (col * columnWidth);
+ int yPos = y + yOffset;
+ renderer(display, nodeList[i].node, xPos, yPos, columnWidth);
+
+ if (extras) {
+ extras(display, nodeList[i].node, xPos, yPos, columnWidth, heading, lat, lon);
+ }
+
+ lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
+ yOffset += rowYOffset;
+ shownCount++;
+ rowCount++;
+
+ if (rowCount >= totalRowsAvailable) {
+ yOffset = 0;
+ rowCount = 0;
+ col++;
+ if (col > (totalColumns - 1))
+ break;
+ }
+ }
+
+ // Draw column separator
+ if (shownCount > 0) {
+ const int firstNodeY = y + 3;
+ drawColumnSeparator(display, x, firstNodeY, lastNodeY);
+ }
+
+ const int scrollStartY = y + 3;
+ drawScrollbar(display, visibleNodeRows, totalEntries, scrollIndex, 2, scrollStartY);
+}
+
+// =============================
+// Screen Frame Functions
+// =============================
+
+#ifndef USE_EINK
+void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // Static variables to track mode and duration
+ static NodeListMode lastRenderedMode = MODE_COUNT;
+ static unsigned long modeStartTime = 0;
+
+ unsigned long now = millis();
+
+ // On very first call (on boot or state enter)
+ if (lastRenderedMode == MODE_COUNT) {
+ currentMode = MODE_LAST_HEARD;
+ modeStartTime = now;
+ }
+
+ // Time to switch to next mode?
+ if (now - modeStartTime >= getModeCycleIntervalMs()) {
+ currentMode = static_cast((currentMode + 1) % MODE_COUNT);
+ modeStartTime = now;
+ }
+
+ // Render screen based on currentMode
+ const char *title = getCurrentModeTitle(display->getWidth());
+ drawNodeListScreen(display, state, x, y, title, drawEntryDynamic);
+
+ // Track the last mode to avoid reinitializing modeStartTime
+ lastRenderedMode = currentMode;
+}
+#endif
+
+#ifdef USE_EINK
+void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Node List";
+ drawNodeListScreen(display, state, x, y, title, drawEntryLastHeard);
+}
+
+void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Hops/Signal";
+ drawNodeListScreen(display, state, x, y, title, drawEntryHopSignal);
+}
+
+void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ const char *title = "Distance";
+ drawNodeListScreen(display, state, x, y, title, drawNodeDistance);
+}
+#endif
+
+void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ float heading = 0;
+ bool validHeading = false;
+ double lat = 0;
+ double lon = 0;
+
+#if HAS_GPS
+ if (screen->hasHeading()) {
+ heading = screen->getHeading(); // degrees
+ validHeading = true;
+ } else {
+ heading = screen->estimatedHeading(lat, lon);
+ validHeading = !isnan(heading);
+ }
+#endif
+
+ if (!validHeading)
+ return;
+
+ drawNodeListScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow, heading, lat, lon);
+}
+
+void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
+{
+ // Cache favorite nodes for the current frame only, to save computation
+ static std::vector favoritedNodes;
+ static int prevFrame = -1;
+
+ // Only rebuild favorites list if we're on a new frame
+ if (state->currentFrame != prevFrame) {
+ prevFrame = state->currentFrame;
+ favoritedNodes.clear();
+ size_t total = nodeDB->getNumMeshNodes();
+ for (size_t i = 0; i < total; i++) {
+ meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
+ // Skip nulls and ourself
+ if (!n || n->num == nodeDB->getNodeNum())
+ continue;
+ if (n->is_favorite)
+ favoritedNodes.push_back(n);
+ }
+ // Keep a stable, consistent display order
+ std::sort(favoritedNodes.begin(), favoritedNodes.end(),
+ [](meshtastic_NodeInfoLite *a, meshtastic_NodeInfoLite *b) { return a->num < b->num; });
+ }
+ if (favoritedNodes.empty())
+ return;
+
+ // Only display if index is valid
+ int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size());
+ if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size())
+ return;
+
+ meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
+ if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
+ return;
+
+ display->clear();
+
+ // Draw battery/time/mail header (common across screens)
+ graphics::drawCommonHeader(display, x, y);
+
+ // Draw the short node name centered at the top, with bold shadow if set
+ const int highlightHeight = FONT_HEIGHT_SMALL - 1;
+ const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
+ const int centerX = x + SCREEN_WIDTH / 2;
+ const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
+
+ if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED)
+ display->setColor(BLACK);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ display->setFont(FONT_SMALL);
+ display->drawString(centerX, textY, shortName);
+ if (config.display.heading_bold)
+ display->drawString(centerX + 1, textY, shortName);
+
+ display->setColor(WHITE);
+ display->setTextAlignment(TEXT_ALIGN_LEFT);
+ display->setFont(FONT_SMALL);
+
+ // Dynamic row stacking with predefined Y positions
+ const int yPositions[5] = {moreCompactFirstLine, moreCompactSecondLine, moreCompactThirdLine, moreCompactFourthLine,
+ moreCompactFifthLine};
+ int line = 0;
+
+ // 1. Long Name (always try to show first)
+ const char *username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
+ if (username && line < 5) {
+ display->drawString(x, yPositions[line++], username);
+ }
+
+ // 2. Signal and Hops (combined on one line, if available)
+ char signalHopsStr[32] = "";
+ bool haveSignal = false;
+ int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
+ const char *signalLabel = " Sig";
+
+ // If SNR looks reasonable, show signal
+ if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
+ snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
+ haveSignal = true;
+ }
+ // If hops is valid (>0), show right after signal
+ if (node->hops_away > 0) {
+ size_t len = strlen(signalHopsStr);
+ if (haveSignal) {
+ snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
+ (node->hops_away == 1 ? "Hop" : "Hops"));
+ } else {
+ snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
+ }
+ }
+ if (signalHopsStr[0] && line < 5) {
+ display->drawString(x, yPositions[line++], signalHopsStr);
+ }
+
+ // 3. Heard (last seen, skip if node never seen)
+ char seenStr[20] = "";
+ uint32_t seconds = sinceLastSeen(node);
+ if (seconds != 0 && seconds != UINT32_MAX) {
+ uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
+ snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard: ?" : " Heard: %d%c ago"),
+ (days ? days
+ : hours ? hours
+ : minutes),
+ (days ? 'd'
+ : hours ? 'h'
+ : 'm'));
+ }
+ if (seenStr[0] && line < 5) {
+ display->drawString(x, yPositions[line++], seenStr);
+ }
+
+ // 4. Uptime (only show if metric is present)
+ char uptimeStr[32] = "";
+ if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
+ uint32_t uptime = node->device_metrics.uptime_seconds;
+ uint32_t days = uptime / 86400;
+ uint32_t hours = (uptime % 86400) / 3600;
+ uint32_t mins = (uptime % 3600) / 60;
+
+ if (days > 0) {
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dd %dh", days, hours);
+ } else if (hours > 0) {
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dh %dm", hours, mins);
+ } else {
+ snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %dm", mins);
+ }
+ }
+ if (uptimeStr[0] && line < 5) {
+ display->drawString(x, yPositions[line++], uptimeStr);
+ }
+
+ // 5. Distance (only if both nodes have GPS position)
+ meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
+ char distStr[24] = "";
+ bool haveDistance = false;
+
+ if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
+ double lat1 = ourNode->position.latitude_i * 1e-7;
+ double lon1 = ourNode->position.longitude_i * 1e-7;
+ double lat2 = node->position.latitude_i * 1e-7;
+ double lon2 = node->position.longitude_i * 1e-7;
+
+ double earthRadiusKm = 6371.0;
+ double dLat = (lat2 - lat1) * DEG_TO_RAD;
+ double dLon = (lon2 - lon1) * DEG_TO_RAD;
+
+ double a =
+ sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
+ double c = 2 * atan2(sqrt(a), sqrt(1 - a));
+ double distanceKm = earthRadiusKm * c;
+
+ // Format distance appropriately
+ if (distanceKm < 1.0) {
+ double miles = distanceKm * 0.621371;
+ if (miles < 0.1) {
+ int feet = (int)(miles * 5280);
+ if (feet > 0 && feet < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dft", feet);
+ haveDistance = true;
+ } else if (feet >= 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: ¼mi");
+ haveDistance = true;
+ }
+ } else {
+ int roundedMiles = (int)(miles + 0.5);
+ if (roundedMiles > 0 && roundedMiles < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles);
+ haveDistance = true;
+ }
+ }
+ } else {
+ int km = (int)(distanceKm + 0.5);
+ if (km > 0 && km < 1000) {
+ snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
+ haveDistance = true;
+ }
+ }
+ }
+ // Only display if we actually have a value!
+ if (haveDistance && distStr[0] && line < 5) {
+ display->drawString(x, yPositions[line++], distStr);
+ }
+
+ // Compass rendering for different screen orientations
+ if (SCREEN_WIDTH > SCREEN_HEIGHT) {
+ // Landscape: side-aligned compass
+ bool showCompass = false;
+ if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
+ showCompass = true;
+ }
+ if (showCompass) {
+ const int16_t topY = compactFirstLine;
+ const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
+ const int16_t usableHeight = bottomY - topY - 5;
+ int16_t compassRadius = usableHeight / 2;
+ if (compassRadius < 8)
+ compassRadius = 8;
+ const int16_t compassDiam = compassRadius * 2;
+ const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
+ const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
+
+ const auto &op = ourNode->position;
+ float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
+ : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
+ CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading);
+
+ const auto &p = node->position;
+ float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
+ if (!config.display.compass_north_top)
+ bearing -= myHeading;
+ CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
+
+ display->drawCircle(compassX, compassY, compassRadius);
+ }
+ } else {
+ // Portrait: bottom-centered compass
+ bool showCompass = false;
+ if (ourNode && (nodeDB->hasValidPosition(ourNode) || screen->hasHeading()) && nodeDB->hasValidPosition(node)) {
+ showCompass = true;
+ }
+ if (showCompass) {
+ int yBelowContent = (line > 0 && line <= 5) ? (yPositions[line - 1] + FONT_HEIGHT_SMALL + 2) : moreCompactFirstLine;
+
+ const int margin = 4;
+#if defined(USE_EINK)
+ const int iconSize = (SCREEN_WIDTH > 128) ? 16 : 8;
+ const int navBarHeight = iconSize + 6;
+#else
+ const int navBarHeight = 0;
+#endif
+ int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
+
+ if (availableHeight < FONT_HEIGHT_SMALL * 2)
+ return;
+
+ int compassRadius = availableHeight / 2;
+ if (compassRadius < 8)
+ compassRadius = 8;
+ if (compassRadius * 2 > SCREEN_WIDTH - 16)
+ compassRadius = (SCREEN_WIDTH - 16) / 2;
+
+ int compassX = x + SCREEN_WIDTH / 2;
+ int compassY = yBelowContent + availableHeight / 2;
+
+ const auto &op = ourNode->position;
+ float myHeading = screen->hasHeading() ? screen->getHeading() * PI / 180
+ : screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i));
+ CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading);
+
+ const auto &p = node->position;
+ float bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
+ if (!config.display.compass_north_top)
+ bearing -= myHeading;
+ CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
+
+ display->drawCircle(compassX, compassY, compassRadius);
+ }
+ }
+}
+
+} // namespace NodeListRenderer
+} // namespace graphics
diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h
new file mode 100644
index 000000000..c583c8ef0
--- /dev/null
+++ b/src/graphics/draw/NodeListRenderer.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Node list and entry rendering functions
+ *
+ * Contains all functions related to drawing node lists and individual node entries
+ * including last heard, hop signal, distance, and compass views.
+ */
+namespace NodeListRenderer
+{
+// Entry renderer function types
+typedef void (*EntryRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int);
+typedef void (*NodeExtrasRenderer)(OLEDDisplay *, meshtastic_NodeInfoLite *, int16_t, int16_t, int, float, double, double);
+
+// Node entry structure
+struct NodeEntry {
+ meshtastic_NodeInfoLite *node;
+ uint32_t sortValue;
+};
+
+// Node list mode enumeration
+enum NodeListMode { MODE_LAST_HEARD = 0, MODE_HOP_SIGNAL = 1, MODE_DISTANCE = 2, MODE_COUNT = 3 };
+
+// Main node list screen function
+void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title,
+ EntryRenderer renderer, NodeExtrasRenderer extras = nullptr, float heading = 0, double lat = 0,
+ double lon = 0);
+
+// Entry renderers
+void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryDynamic(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth);
+
+// Extras renderers
+void drawCompassArrow(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth, float myHeading,
+ double userLat, double userLon);
+
+// Screen frame functions
+void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDynamicNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Utility functions
+const char *getCurrentModeTitle(int screenWidth);
+void retrieveAndSortNodes(std::vector &nodeList);
+String getSafeNodeName(meshtastic_NodeInfoLite *node);
+uint32_t sinceLastSeen(meshtastic_NodeInfoLite *node);
+
+// Bitmap drawing function
+void drawScaledXBitmap16x16(int x, int y, int width, int height, const uint8_t *bitmapXBM, OLEDDisplay *display);
+
+} // namespace NodeListRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/ScreenRenderer.h b/src/graphics/draw/ScreenRenderer.h
new file mode 100644
index 000000000..9150f931c
--- /dev/null
+++ b/src/graphics/draw/ScreenRenderer.h
@@ -0,0 +1,37 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include
+#include
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief Screen-specific drawing functions
+ *
+ * Contains drawing functions for specific screen types like GPS location,
+ * memory usage, device info, and other specialized screens.
+ */
+namespace ScreenRenderer
+{
+// Screen frame functions
+void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawMemoryScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Text message screen
+void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+// Module and firmware frames
+void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
+} // namespace ScreenRenderer
+
+} // namespace graphics
diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h
new file mode 100644
index 000000000..770e5010d
--- /dev/null
+++ b/src/graphics/draw/UIRenderer.h
@@ -0,0 +1,65 @@
+#pragma once
+
+#include "graphics/Screen.h"
+#include "graphics/emotes.h"
+#include
+#include
+#include
+
+// Forward declarations for status types
+namespace meshtastic
+{
+class PowerStatus;
+class NodeStatus;
+class GPSStatus;
+} // namespace meshtastic
+
+namespace graphics
+{
+
+/// Forward declarations
+class Screen;
+
+/**
+ * @brief UI utility drawing functions
+ *
+ * Contains utility functions for drawing common UI elements, overlays,
+ * battery indicators, and other shared graphical components.
+ */
+namespace UIRenderer
+{
+// Common UI elements
+void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y);
+void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, const meshtastic::PowerStatus *powerStatus);
+void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset = 0,
+ bool show_total = true, String additional_words = "");
+
+// GPS status functions
+void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+void drawGPScoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+void drawGPSAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus);
+
+// Layout and utility functions
+void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
+void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t startY, int16_t endY);
+void drawScrollbar(OLEDDisplay *display, int visibleItems, int totalItems, int scrollIndex, int x, int startY);
+
+// Overlay and special screens
+void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state);
+void drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *text);
+
+// Text and emote rendering
+void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount);
+
+// Time and date utilities
+void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength);
+std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
+void formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
+
+// Message filtering
+bool shouldDrawMessage(const meshtastic_MeshPacket *packet);
+
+} // namespace UIRenderer
+
+} // namespace graphics