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