From 77e7235ce5d116ef1442bbf8d288a0e577a2f4d3 Mon Sep 17 00:00:00 2001 From: Ben Meadors Date: Sat, 31 May 2025 19:31:45 -0500 Subject: [PATCH] Screen refactor / move to renderers (#6932) * WIP Screen.cpp refactoring * WIP * Notification and time * Draw nodes and device focused * Namespacing and more moved methods * Move EInk ones * Eink fixes * Remove useless wrapper functions * Update alignments and spacing * Update src/graphics/draw/NotificationRenderer.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fully qualify * Move drawfunctionoverlay * Put the imperial back * CompassRenderer methods * Moar * Another * Fixed compassarrow renderer * Draw columns * Name cleanup --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/graphics/Screen.cpp | 3127 +------------------- src/graphics/Screen.h | 29 +- src/graphics/ScreenGlobals.cpp | 6 + src/graphics/draw/ClockRenderer.h | 40 + src/graphics/draw/CompassRenderer.cpp | 132 + src/graphics/draw/CompassRenderer.h | 36 + src/graphics/draw/DebugRenderer.cpp | 671 +++++ src/graphics/draw/DebugRenderer.h | 38 + src/graphics/draw/DrawRenderers.h | 38 + src/graphics/draw/MessageRenderer.cpp | 448 +++ src/graphics/draw/MessageRenderer.h | 18 + src/graphics/draw/NodeListRenderer.cpp | 877 ++++++ src/graphics/draw/NodeListRenderer.h | 71 + src/graphics/draw/NotificationRenderer.cpp | 177 ++ src/graphics/draw/NotificationRenderer.h | 24 + src/graphics/draw/UIRenderer.cpp | 1254 ++++++++ src/graphics/draw/UIRenderer.h | 92 + src/modules/WaypointModule.cpp | 11 +- src/motion/MotionSensor.cpp | 5 +- 19 files changed, 3982 insertions(+), 3112 deletions(-) create mode 100644 src/graphics/ScreenGlobals.cpp create mode 100644 src/graphics/draw/ClockRenderer.h create mode 100644 src/graphics/draw/CompassRenderer.cpp create mode 100644 src/graphics/draw/CompassRenderer.h create mode 100644 src/graphics/draw/DebugRenderer.cpp create mode 100644 src/graphics/draw/DebugRenderer.h create mode 100644 src/graphics/draw/DrawRenderers.h create mode 100644 src/graphics/draw/MessageRenderer.cpp create mode 100644 src/graphics/draw/MessageRenderer.h create mode 100644 src/graphics/draw/NodeListRenderer.cpp create mode 100644 src/graphics/draw/NodeListRenderer.h create mode 100644 src/graphics/draw/NotificationRenderer.cpp create mode 100644 src/graphics/draw/NotificationRenderer.h create mode 100644 src/graphics/draw/UIRenderer.cpp create mode 100644 src/graphics/draw/UIRenderer.h diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 9c018ad70..22842adaf 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -28,6 +28,11 @@ along with this program. If not, see . #include #include "DisplayFormatters.h" +#include "draw/DebugRenderer.h" +#include "draw/MessageRenderer.h" +#include "draw/NodeListRenderer.h" +#include "draw/NotificationRenderer.h" +#include "draw/UIRenderer.h" #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif @@ -66,8 +71,6 @@ using graphics::numEmotes; #endif #ifdef ARCH_ESP32 -#include "esp_task_wdt.h" -#include "modules/StoreForwardModule.h" #endif #if ARCH_PORTDUINO @@ -99,8 +102,6 @@ static uint32_t alertBannerUntil = 0; uint32_t logo_timeout = 5000; // 4 seconds for EACH logo -uint32_t hours_in_month = 730; - // This image definition is here instead of images.h because it's modified dynamically by the drawBattery function uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C}; @@ -111,13 +112,15 @@ uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100}; // we'll need to hold onto pointers for the modules that can draw a frame. std::vector moduleFrames; -// Stores the last 4 of our hardware ID, to make finding the device for pairing easier -static char ourId[5]; - -// vector where symbols (string) are displayed in bottom corner of display. +// Global variables for screen function overlay symbols std::vector functionSymbol; -// string displayed in bottom right corner of display. Created from elements in functionSymbol vector -std::string functionSymbolString = ""; +std::string functionSymbolString; + +// Stores the last 4 of our hardware ID, to make finding the device for pairing easier +// FIXME: Needs refactoring and getMacAddr needs to be moved to a utility class +extern "C" { +char ourId[5]; +} #if HAS_GPS // GeoCoord object for the screen @@ -131,291 +134,11 @@ static bool heartbeat = false; #include "graphics/ScreenFonts.h" #include -// Start Functions to write date/time to the screen -#include // Only needed if you're using std::string elsewhere - -bool isLeapYear(int year) -{ - return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); -} - -const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; - -// Fills the buffer with a formatted date/time string and returns pixel width -int formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime) -{ - int sec = rtc_sec % 60; - rtc_sec /= 60; - int min = rtc_sec % 60; - rtc_sec /= 60; - int hour = rtc_sec % 24; - rtc_sec /= 24; - - int year = 1970; - while (true) { - int daysInYear = isLeapYear(year) ? 366 : 365; - if (rtc_sec >= (uint32_t)daysInYear) { - rtc_sec -= daysInYear; - year++; - } else { - break; - } - } - - int month = 0; - while (month < 12) { - int dim = daysInMonth[month]; - if (month == 1 && isLeapYear(year)) - dim++; - if (rtc_sec >= (uint32_t)dim) { - rtc_sec -= dim; - month++; - } else { - break; - } - } - - int day = rtc_sec + 1; - - if (includeTime) { - snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec); - } else { - snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day); - } - - return display->getStringWidth(buf); -} - // 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) -{ -#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS) - // Don't want to make any assumptions about custom language support - return true; -#endif - - // Check each character with the lookup function for the OLED library - // We're not really meant to use this directly.. - bool have = true; - for (uint16_t i = 0; i < strlen(str); i++) { - uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); - // If font doesn't support a character, it is substituted for ¿ - if (result == 191 && (uint8_t)str[i] != 191) { - have = false; - break; - } - } - - // LOG_DEBUG("haveGlyphs=%d", have); - return have; -} extern bool hasUnreadMessage; -/** - * Draw the icon with extra info printed around the corners - */ -static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - const char *label = "BaseUI"; - display->setFont(FONT_SMALL); - int textWidth = display->getStringWidth(label); - int r = 3; // corner radius - if (SCREEN_WIDTH > 128) { - // === ORIGINAL WIDE SCREEN LAYOUT (unchanged) === - int padding = 4; - int boxWidth = max(icon_width, textWidth) + (padding * 2) + 16; - int boxHeight = icon_height + FONT_HEIGHT_SMALL + (padding * 3) - 8; - int boxX = x - 1 + (SCREEN_WIDTH - boxWidth) / 2; - int boxY = y - 6 + (SCREEN_HEIGHT - boxHeight) / 2; - - display->setColor(WHITE); - display->fillRect(boxX + r, boxY, boxWidth - 2 * r, boxHeight); - display->fillRect(boxX, boxY + r, boxWidth - 1, boxHeight - 2 * r); - display->fillCircle(boxX + r, boxY + r, r); // Upper Left - display->fillCircle(boxX + boxWidth - r - 1, boxY + r, r); // Upper Right - display->fillCircle(boxX + r, boxY + boxHeight - r - 1, r); // Lower Left - display->fillCircle(boxX + boxWidth - r - 1, boxY + boxHeight - r - 1, r); // Lower Right - - display->setColor(BLACK); - int iconX = boxX + (boxWidth - icon_width) / 2; - int iconY = boxY + padding - 2; - display->drawXbm(iconX, iconY, icon_width, icon_height, icon_bits); - - int labelY = iconY + icon_height + padding; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(x + SCREEN_WIDTH / 2 - 3, labelY, label); - display->drawString(x + SCREEN_WIDTH / 2 - 2, labelY, label); // faux bold - - } else { - // === TIGHT SMALL SCREEN LAYOUT === - int iconY = y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2; - iconY -= 4; - - int labelY = iconY + icon_height - 2; - - int boxWidth = max(icon_width, textWidth) + 4; - int boxX = x + (SCREEN_WIDTH - boxWidth) / 2; - int boxY = iconY - 1; - int boxBottom = labelY + FONT_HEIGHT_SMALL - 2; - int boxHeight = boxBottom - boxY; - - display->setColor(WHITE); - display->fillRect(boxX + r, boxY, boxWidth - 2 * r, boxHeight); - display->fillRect(boxX, boxY + r, boxWidth - 1, boxHeight - 2 * r); - display->fillCircle(boxX + r, boxY + r, r); - display->fillCircle(boxX + boxWidth - r - 1, boxY + r, r); - display->fillCircle(boxX + r, boxY + boxHeight - r - 1, r); - display->fillCircle(boxX + boxWidth - r - 1, boxY + boxHeight - r - 1, r); - - display->setColor(BLACK); - int iconX = boxX + (boxWidth - icon_width) / 2; - display->drawXbm(iconX, iconY, icon_width, icon_height, icon_bits); - - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(x + SCREEN_WIDTH / 2, labelY, label); - } - - // === Footer and headers (shared) === - display->setFont(FONT_MEDIUM); - display->setColor(WHITE); - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *title = "meshtastic.org"; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); - - display->setFont(FONT_SMALL); - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); - - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); - - screen->forceDisplay(); - display->setTextAlignment(TEXT_ALIGN_LEFT); -} - -#ifdef USERPREFS_OEM_TEXT - -static void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA; - display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2, - y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH, - USERPREFS_OEM_IMAGE_HEIGHT, xbm); - - switch (USERPREFS_OEM_FONT_SIZE) { - case 0: - display->setFont(FONT_SMALL); - break; - case 2: - display->setFont(FONT_LARGE); - break; - default: - display->setFont(FONT_MEDIUM); - break; - } - - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *title = USERPREFS_OEM_TEXT; - display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); - display->setFont(FONT_SMALL); - - // Draw region in upper left - if (upperMsg) - display->drawString(x + 0, y + 0, upperMsg); - - // Draw version and shortname in upper right - char buf[25]; - snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); - - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(x + SCREEN_WIDTH, y + 0, buf); - screen->forceDisplay(); - - display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code -} - -static void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - // Draw region in upper left - const char *region = myRegion ? myRegion->name : NULL; - drawOEMIconScreen(region, display, state, x, y); -} - -#endif - -void Screen::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) -{ - uint16_t x_offset = display->width() / 2; - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(x_offset + x, 26 + y, message); -} - -// Used on boot when a certificate is being created -static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_SMALL); - display->drawString(64 + x, y, "Creating SSL certificate"); - -#ifdef ARCH_ESP32 - yield(); - esp_task_wdt_reset(); -#endif - - display->setFont(FONT_SMALL); - if ((millis() / 1000) % 2) { - display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); - } else { - display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); - } -} - -// Used when booting without a region set -static void drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(64 + x, y, "//\\ E S H T /\\ S T / C"); - display->drawString(64 + x, y + FONT_HEIGHT_SMALL, getDeviceName()); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if ((millis() / 10000) % 2) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Set the region using the"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "Meshtastic Android, iOS,"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "Web or CLI clients."); - } else { - display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Visit meshtastic.org"); - display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "for more information."); - display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, ""); - } - -#ifdef ARCH_ESP32 - yield(); - esp_task_wdt_reset(); -#endif -} // ============================== // Overlay Alert Banner Renderer // ============================== @@ -430,160 +153,6 @@ void Screen::showOverlayBanner(const String &message, uint32_t durationMs) alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; } -// Draws the overlay banner on screen, if still within display duration -static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - // Exit if no message is active or duration has passed - if (alertBannerMessage.length() == 0 || (alertBannerUntil != 0 && millis() > alertBannerUntil)) - return; - - // === Layout Configuration === - constexpr uint16_t padding = 5; // Padding around text inside the box - constexpr uint8_t lineSpacing = 1; // Extra space between lines - - // Search the mesage to determine if we need the bell added - bool needs_bell = (alertBannerMessage.indexOf("Alert Received") != -1); - - // Setup font and alignment - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); // We will manually center per line - - // === Split the message into lines (supports multi-line banners) === - std::vector lines; - int start = 0, newlineIdx; - while ((newlineIdx = alertBannerMessage.indexOf('\n', start)) != -1) { - lines.push_back(alertBannerMessage.substring(start, newlineIdx)); - start = newlineIdx + 1; - } - lines.push_back(alertBannerMessage.substring(start)); - - // === Measure text dimensions === - uint16_t minWidth = (SCREEN_WIDTH > 128) ? 106 : 78; - uint16_t maxWidth = 0; - std::vector lineWidths; - for (const auto &line : lines) { - uint16_t w = display->getStringWidth(line.c_str(), line.length(), true); - lineWidths.push_back(w); - if (w > maxWidth) - maxWidth = w; - } - - uint16_t boxWidth = padding * 2 + maxWidth; - if (needs_bell && boxWidth < minWidth) - boxWidth += (SCREEN_WIDTH > 128) ? 26 : 20; - - uint16_t boxHeight = padding * 2 + lines.size() * FONT_HEIGHT_SMALL + (lines.size() - 1) * lineSpacing; - - int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); - int16_t boxTop = (display->height() / 2) - (boxHeight / 2); - - // === Draw background box === - display->setColor(BLACK); - display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Slightly oversized box - display->setColor(WHITE); - display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Border - - // === Draw each line centered in the box === - int16_t lineY = boxTop + padding; - for (size_t i = 0; i < lines.size(); ++i) { - int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; - uint16_t line_width = display->getStringWidth(lines[i].c_str(), lines[i].length(), true); - - if (needs_bell && i == 0) { - int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2; - display->drawXbm(textX - 10, bellY, 8, 8, bell_alert); - display->drawXbm(textX + line_width + 2, bellY, 8, 8, bell_alert); - } - - display->drawString(textX, lineY, lines[i]); - if (SCREEN_WIDTH > 128) - display->drawString(textX + 1, lineY, lines[i]); // Faux bold - - lineY += FONT_HEIGHT_SMALL + lineSpacing; - } -} - -// draw overlay in bottom right corner of screen to show when notifications are muted or modifier key is active -static void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - // LOG_DEBUG("Draw function overlay"); - if (functionSymbol.begin() != functionSymbol.end()) { - char buf[64]; - display->setFont(FONT_SMALL); - snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str()); - display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf); - } -} - -#ifdef USE_EINK -/// Used on eink displays while in deep sleep -static void drawDeepSleepScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - - // Next frame should use full-refresh, and block while running, else device will sleep before async callback - EINK_ADD_FRAMEFLAG(display, COSMETIC); - EINK_ADD_FRAMEFLAG(display, BLOCKING); - - LOG_DEBUG("Draw deep sleep screen"); - - // Display displayStr on the screen - drawIconScreen("Sleeping", display, state, x, y); -} - -/// Used on eink displays when screen updates are paused -static void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - LOG_DEBUG("Draw screensaver overlay"); - - EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh - - // Config - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - const char *pauseText = "Screen Paused"; - const char *idText = owner.short_name; - const bool useId = haveGlyphs(idText); // This bool is used to hide the idText box if we can't render the short name - constexpr uint16_t padding = 5; - constexpr uint8_t dividerGap = 1; - constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in. - - // Dimensions - const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars - const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); - const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding; - const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding; - - // Position - const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1); - // const int16_t boxRight = boxLeft + boxWidth - 1; - const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1)); - const int16_t boxBottom = boxTop + boxHeight - 1; - const int16_t idTextLeft = boxLeft + padding; - const int16_t idTextTop = boxTop + padding; - const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding; - const int16_t pauseTextTop = boxTop + padding; - const int16_t dividerX = boxLeft + padding + idTextWidth + padding; - const int16_t dividerTop = boxTop + 1 + dividerGap; - const int16_t dividerBottom = boxBottom - 1 - dividerGap; - - // Draw: box - display->setColor(EINK_WHITE); - display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box - display->setColor(EINK_BLACK); - display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); - - // Draw: Text - if (useId) - display->drawString(idTextLeft, idTextTop, idText); - display->drawString(pauseTextLeft, pauseTextTop, pauseText); - display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold - - // Draw: divider - if (useId) - display->drawLine(dividerX, dividerTop, dividerX, dividerBottom); -} -#endif - static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { uint8_t module_frame; @@ -606,78 +175,12 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int pi.drawFrame(display, state, x, y); } -static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->setFont(FONT_MEDIUM); - display->drawString(64 + x, y, "Updating"); - - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), - "Please be patient and do not power off."); -} - -/// Draw the last text message we received -static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_MEDIUM); - - char tempBuf[24]; - snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); - display->drawString(0 + x, 0 + y, tempBuf); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); -} - // Ignore messages originating from phone (from the current node 0x0) unless range test or store and forward module are enabled static bool shouldDrawMessage(const meshtastic_MeshPacket *packet) { return packet->from != 0 && !moduleConfig.store_forward.enabled; } -// Draw power bars or a charging indicator on an image of a battery, determined by battery charge voltage or percentage. -static void drawBattery(OLEDDisplay *display, int16_t x, int16_t y, uint8_t *imgBuffer, const PowerStatus *powerStatus) -{ - static const uint8_t powerBar[3] = {0x81, 0xBD, 0xBD}; - static const uint8_t lightning[8] = {0xA1, 0xA1, 0xA5, 0xAD, 0xB5, 0xA5, 0x85, 0x85}; - - // Clear the bar area inside the battery image - for (int i = 1; i < 14; i++) { - imgBuffer[i] = 0x81; - } - - // Fill with lightning or power bars - if (powerStatus->getIsCharging()) { - memcpy(imgBuffer + 3, lightning, 8); - } else { - for (int i = 0; i < 4; i++) { - if (powerStatus->getBatteryChargePercent() >= 25 * i) - memcpy(imgBuffer + 1 + (i * 3), powerBar, 3); - } - } - - // Slightly more conservative scaling based on screen width - int scale = 1; - - if (SCREEN_WIDTH >= 200) - scale = 2; - if (SCREEN_WIDTH >= 300) - scale = 2; // Do NOT go higher than 2 - - // Draw scaled battery image (16 columns × 8 rows) - for (int col = 0; col < 16; col++) { - uint8_t colBits = imgBuffer[col]; - for (int row = 0; row < 8; row++) { - if (colBits & (1 << row)) { - display->fillRect(x + col * scale, y + row * scale, scale, scale); - } - } - } -} - #if defined(DISPLAY_CLOCK_FRAME) void Screen::drawWatchFaceToggleButton(OLEDDisplay *display, int16_t x, int16_t y, bool digitalMode, float scale) @@ -1186,542 +689,6 @@ bool deltaToTimestamp(uint32_t secondsAgo, uint8_t *hours, uint8_t *minutes, int return validCached; } -void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount) -{ - int cursorX = x; - const int fontHeight = FONT_HEIGHT_SMALL; - - // === Step 1: Find tallest emote in the line === - int maxIconHeight = fontHeight; - for (size_t i = 0; i < line.length();) { - bool matched = false; - for (int e = 0; e < emoteCount; ++e) { - size_t emojiLen = strlen(emotes[e].label); - if (line.compare(i, emojiLen, emotes[e].label) == 0) { - if (emotes[e].height > maxIconHeight) - maxIconHeight = emotes[e].height; - i += emojiLen; - matched = true; - break; - } - } - if (!matched) { - uint8_t c = static_cast(line[i]); - if ((c & 0xE0) == 0xC0) - i += 2; - else if ((c & 0xF0) == 0xE0) - i += 3; - else if ((c & 0xF8) == 0xF0) - i += 4; - else - i += 1; - } - } - - // === Step 2: Baseline alignment === - int lineHeight = std::max(fontHeight, maxIconHeight); - int baselineOffset = (lineHeight - fontHeight) / 2; - int fontY = y + baselineOffset; - int fontMidline = fontY + fontHeight / 2; - - // === Step 3: Render line in segments === - size_t i = 0; - bool inBold = false; - - while (i < line.length()) { - // Check for ** start/end for faux bold - if (line.compare(i, 2, "**") == 0) { - inBold = !inBold; - i += 2; - continue; - } - - // Look ahead for the next emote match - size_t nextEmotePos = std::string::npos; - const Emote *matchedEmote = nullptr; - size_t emojiLen = 0; - - for (int e = 0; e < emoteCount; ++e) { - size_t pos = line.find(emotes[e].label, i); - if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) { - nextEmotePos = pos; - matchedEmote = &emotes[e]; - emojiLen = strlen(emotes[e].label); - } - } - - // Render normal text segment up to the emote or bold toggle - size_t nextControl = std::min(nextEmotePos, line.find("**", i)); - if (nextControl == std::string::npos) - nextControl = line.length(); - - if (nextControl > i) { - std::string textChunk = line.substr(i, nextControl - i); - if (inBold) { - // Faux bold: draw twice, offset by 1px - display->drawString(cursorX + 1, fontY, textChunk.c_str()); - } - display->drawString(cursorX, fontY, textChunk.c_str()); - cursorX += display->getStringWidth(textChunk.c_str()); - i = nextControl; - continue; - } - - // Render the emote (if found) - if (matchedEmote && i == nextEmotePos) { - int iconY = fontMidline - matchedEmote->height / 2 - 1; - display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); - cursorX += matchedEmote->width + 1; - i += emojiLen; - } else { - // No more emotes — render the rest of the line - std::string remaining = line.substr(i); - if (inBold) { - display->drawString(cursorX + 1, fontY, remaining.c_str()); - } - display->drawString(cursorX, fontY, remaining.c_str()); - cursorX += display->getStringWidth(remaining.c_str()); - break; - } - } -} - -// **************************** -// * 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); - } - } - } -} - -/// Draw a series of fields in a column, wrapping to multiple columns if needed -void Screen::drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) -{ - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - const char **f = fields; - int xo = x, yo = y; - while (*f) { - display->drawString(xo, yo, *f); - if ((display->getColor() == BLACK) && config.display.heading_bold) - display->drawString(xo + 1, yo, *f); - - display->setColor(WHITE); - yo += FONT_HEIGHT_SMALL; - if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { - xo += SCREEN_WIDTH / 2; - yo = 0; - } - f++; - } -} - -// Draw nodes status -static void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const NodeStatus *nodeStatus, int node_offset = 0, - bool show_total = true, String additional_words = "") -{ - char usersString[20]; - int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0; - - snprintf(usersString, sizeof(usersString), "%d", nodes_online); - - if (show_total) { - int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0; - snprintf(usersString, sizeof(usersString), "%d/%d", nodes_online, nodes_total); - } - -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x, y + 3, 8, 8, imgUser); -#else - display->drawFastImage(x, y + 1, 8, 8, imgUser); -#endif - display->drawString(x + 10, y - 2, usersString); - int string_offset = (SCREEN_WIDTH > 128) ? 2 : 1; - if (additional_words != "") { - display->drawString(x + 10 + display->getStringWidth(usersString) + string_offset, y - 2, additional_words); - if (config.display.heading_bold) - display->drawString(x + 11 + display->getStringWidth(usersString) + string_offset, y - 2, additional_words); - } -} -#if HAS_GPS -// Draw GPS status summary -static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - if (config.position.fixed_position) { - // GPS coordinates are currently fixed - display->drawString(x - 1, y - 2, "Fixed GPS"); - if (config.display.heading_bold) - display->drawString(x, y - 2, "Fixed GPS"); - return; - } - if (!gps->getIsConnected()) { - display->drawString(x, y - 2, "No GPS"); - if (config.display.heading_bold) - display->drawString(x + 1, y - 2, "No GPS"); - return; - } - // Adjust position if we’re going to draw too wide - int maxDrawWidth = 6; // Position icon - - if (!gps->getHasLock()) { - maxDrawWidth += display->getStringWidth("No sats") + 2; // icon + text + buffer - } else { - maxDrawWidth += (5 * 2) + 8 + display->getStringWidth("99") + 2; // bars + sat icon + text + buffer - } - - if (x + maxDrawWidth > SCREEN_WIDTH) { - x = SCREEN_WIDTH - maxDrawWidth; - if (x < 0) - x = 0; // Clamp to screen - } - - display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty); - if (!gps->getHasLock()) { - // Draw "No sats" to the right of the icon with slightly more gap - int textX = x + 9; // 6 (icon) + 3px spacing - display->drawString(textX, y - 3, "No sats"); - if (config.display.heading_bold) - display->drawString(textX + 1, y - 3, "No sats"); - return; - } else { - char satsString[3]; - uint8_t bar[2] = {0}; - - // Draw DOP signal bars - for (int i = 0; i < 5; i++) { - if (gps->getDOP() <= dopThresholds[i]) - bar[0] = ~((1 << (5 - i)) - 1); - else - bar[0] = 0b10000000; - - display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); - } - - // Draw satellite image - display->drawFastImage(x + 24, y, 8, 8, imgSatellite); - - // Draw the number of satellites - snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); - int textX = x + 34; - display->drawString(textX, y - 2, satsString); - if (config.display.heading_bold) - display->drawString(textX + 1, y - 2, satsString); - } -} - -// Draw status when GPS is disabled or not present -static void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - String displayLine; - int pos; - if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; - pos = SCREEN_WIDTH - display->getStringWidth(displayLine); - } else { - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present" - : "GPS is disabled"; - pos = (SCREEN_WIDTH - display->getStringWidth(displayLine)) / 2; - } - display->drawString(x + pos, y, displayLine); -} - -static void drawGPSAltitude(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - String displayLine = ""; - if (!gps->getIsConnected() && !config.position.fixed_position) { - // displayLine = "No GPS Module"; - // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else if (!gps->getHasLock() && !config.position.fixed_position) { - // displayLine = "No GPS Lock"; - // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else { - geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); - displayLine = "Altitude: " + String(geoCoord.getAltitude()) + "m"; - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) - displayLine = "Altitude: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } -} - -// Draw GPS status coordinates -static void drawGPScoordinates(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) -{ - auto gpsFormat = config.display.gps_format; - String displayLine = ""; - - if (!gps->getIsConnected() && !config.position.fixed_position) { - displayLine = "No GPS present"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else if (!gps->getHasLock() && !config.position.fixed_position) { - displayLine = "No GPS Lock"; - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); - } else { - - geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); - - if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) { - char coordinateLine[22]; - if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees - snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7, - geoCoord.getLongitude() * 1e-7); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator - snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(), - geoCoord.getUTMEasting(), geoCoord.getUTMNorthing()); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System - snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(), - geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(), - geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing()); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code - geoCoord.getOLCCode(coordinateLine); - } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference - if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region - snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary"); - else - snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(), - geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing()); - } - - // If fixed position, display text "Fixed GPS" alternating with the coordinates. - if (config.position.fixed_position) { - if ((millis() / 10000) % 2) { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); - } else { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS"); - } - } else { - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); - } - } else { - char latLine[22]; - char lonLine[22]; - snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(), - geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP()); - snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), - geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, latLine); - display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(lonLine))) / 2, y, lonLine); - } - } -} -#endif /** * Given a recent lat/lon return a guess of the heading the user is walking on. * @@ -1756,35 +723,6 @@ float Screen::estimatedHeading(double lat, double lon) /// nodes static int8_t prevFrame = -1; -// Draw the arrow pointing to a node's location -void Screen::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); - } - /* Old arrow - display->drawLine(tip.x, tip.y, tail.x, tail.y); - display->drawLine(leftArrow.x, leftArrow.y, tip.x, tip.y); - display->drawLine(rightArrow.x, rightArrow.y, tip.x, tip.y); - display->drawLine(leftArrow.x, leftArrow.y, tail.x, tail.y); - display->drawLine(rightArrow.x, rightArrow.y, tail.x, tail.y); - */ -#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); -} - // Get a string representation of the time passed since something happened void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) { @@ -1809,1568 +747,15 @@ void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) else if (agoSecs < 120 * 60) // last 2 hrs snprintf(timeStr, maxLength, "%u minutes ago", agoSecs / 60); // Only show hours ago if it's been less than 6 months. Otherwise, we may have bad data. - else if ((agoSecs / 60 / 60) < (hours_in_month * 6)) + else if ((agoSecs / 60 / 60) < (HOURS_IN_MONTH * 6)) snprintf(timeStr, maxLength, "%u hours ago", agoSecs / 60 / 60); else snprintf(timeStr, maxLength, "unknown age"); } -void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading) -{ - Serial.print("🧭 [Main Compass] Raw Heading (deg): "); - Serial.println(myHeading * RAD_TO_DEG); - - // If north is supposed to be at the top of the compass we want rotation to be +0 - if (config.display.compass_north_top) - myHeading = -0; - /* N sign points currently not deleted*/ - Point N1(-0.04f, 0.65f), N2(0.04f, 0.65f); // N sign points (N1-N4) - Point N3(-0.04f, 0.55f), N4(0.04f, 0.55f); - Point NC1(0.00f, 0.50f); // north circle center point - Point *rosePoints[] = {&N1, &N2, &N3, &N4, &NC1}; - - uint16_t compassDiam = Screen::getCompassDiam(SCREEN_WIDTH, SCREEN_HEIGHT); - - for (int i = 0; i < 5; i++) { - // North on compass will be negative of heading - rosePoints[i]->rotate(-myHeading); - rosePoints[i]->scale(compassDiam); - rosePoints[i]->translate(compassX, compassY); - } -} - -uint16_t Screen::getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) -{ - uint16_t diam = 0; - uint16_t offset = 0; - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) - offset = FONT_HEIGHT_SMALL; - - // get the smaller of the 2 dimensions and subtract 20 - if (displayWidth > (displayHeight - offset)) { - diam = displayHeight - offset; - // if 2/3 of the other size would be smaller, use that - if (diam > (displayWidth * 2 / 3)) { - diam = displayWidth * 2 / 3; - } - } else { - diam = displayWidth; - if (diam > ((displayHeight - offset) * 2 / 3)) { - diam = (displayHeight - offset) * 2 / 3; - } - } - - return diam - 20; -}; - -// ********************** -// * Favorite Node Info * -// ********************** -static 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 YOUR MACROS ===== - // 1. Each potential info row has a macro-defined Y position (not regular increments!). - // 2. Each row is only shown if it has valid data. - // 3. Each row "moves up" if previous are empty, so there are never any blank rows. - // 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot. - - // List of available macro Y positions in order, from top to bottom. - const int yPositions[5] = {moreCompactFirstLine, moreCompactSecondLine, moreCompactThirdLine, moreCompactFourthLine, - moreCompactFifthLine}; - int line = 0; // which slot to use next - - // === 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) { - // Print node's long name (e.g. "Backpack Node") - display->drawString(x, yPositions[line++], username); - } - - // === 2. Signal and Hops (combined on one line, if available) === - // If both are present: "Sig: 97% [2hops]" - // If only one: show only that one - char signalHopsStr[32] = ""; - bool haveSignal = false; - int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100); - - // Always use "Sig" for the label - const char *signalLabel = " Sig"; - - // --- Build the Signal/Hops line --- - // 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); - // Decide between "1 Hop" and "N Hops" - 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; - // Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago" - 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; - // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m" - if (days) - snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %ud %uh", days, hours); - else if (hours) - snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %uh %um", hours, mins); - else - snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", 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] = ""; // Make buffer big enough for any string - 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; - - 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 > 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 { - if (distanceKm < 1.0) { - int meters = (int)(distanceKm * 1000); - if (meters > 0 && meters < 1000) { - snprintf(distStr, sizeof(distStr), " Distance: %dm", meters); - haveDistance = true; - } else if (meters >= 1000) { - snprintf(distStr, sizeof(distStr), " Distance: 1km"); - 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: landscape (wide) screens use the original side-aligned logic --- - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - 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)); - screen->drawCompassNorth(display, compassX, compassY, myHeading); - - const auto &p = node->position; - float d = - GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - 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; - screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearing); - - display->drawCircle(compassX, compassY, compassRadius); - } - // else show nothing - } else { - // Portrait or square: put compass at the bottom and centered, scaled to fit available space - 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; -// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) ----------- -#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; - // --------- END PATCH FOR EINK NAV BAR ----------- - - 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)); - screen->drawCompassNorth(display, compassX, compassY, myHeading); - - const auto &p = node->position; - float d = - GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); - 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; - screen->drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); - - display->drawCircle(compassX, compassY, compassRadius); - } - // else show nothing - } -} - // 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 : 3), 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 ? 17 : 25) // Offset for Wide Screens (Left Column:Right Column) - : (isLeftCol ? 13 : 17); // 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 : 3), 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 + 1 + (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 : 3), 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 : 3), 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 * -// **************************** -static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->clear(); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - - // === Header === - graphics::drawCommonHeader(display, x, y); - - // === Content below header === - - // Determine if we need to show 4 or 5 rows on the screen - int rows = 4; - if (!config.bluetooth.enabled) { - rows = 5; - } - - // === First Row: Region / Channel Utilization and Uptime === - bool origBold = config.display.heading_bold; - config.display.heading_bold = false; - - // Display Region and Channel Utilization - drawNodes(display, x + 1, - ((rows == 4) ? compactFirstLine : ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine)) + 2, nodeStatus, - -1, false, "online"); - - uint32_t uptime = millis() / 1000; - char uptimeStr[6]; - uint32_t minutes = uptime / 60, hours = minutes / 60, days = hours / 24; - - if (days > 365) { - snprintf(uptimeStr, sizeof(uptimeStr), "?"); - } else { - snprintf(uptimeStr, sizeof(uptimeStr), "%d%c", - days ? days - : hours ? hours - : minutes ? minutes - : (int)uptime, - days ? 'd' - : hours ? 'h' - : minutes ? 'm' - : 's'); - } - - char uptimeFullStr[16]; - snprintf(uptimeFullStr, sizeof(uptimeFullStr), "Uptime: %s", uptimeStr); - display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeFullStr), - ((rows == 4) ? compactFirstLine : ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine)), - uptimeFullStr); - - config.display.heading_bold = origBold; - - // === Second Row: Satellites and Voltage === - config.display.heading_bold = false; - -#if HAS_GPS - if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - String displayLine = ""; - if (config.position.fixed_position) { - displayLine = "Fixed GPS"; - } else { - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; - } - display->drawString( - 0, ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), - displayLine); - } else { - drawGPS(display, 0, - ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)) + 3, - gpsStatus); - } -#endif - - char batStr[20]; - if (powerStatus->getHasBattery()) { - int batV = powerStatus->getBatteryVoltageMv() / 1000; - int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; - snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv); - display->drawString( - x + SCREEN_WIDTH - display->getStringWidth(batStr), - ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), batStr); - } else { - display->drawString( - x + SCREEN_WIDTH - display->getStringWidth("USB"), - ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), - String("USB")); - } - - config.display.heading_bold = origBold; - - // === Third Row: Bluetooth Off (Only If Actually Off) === - if (!config.bluetooth.enabled) { - display->drawString( - 0, ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactThirdLine : moreCompactThirdLine)), "BT off"); - } - - // === Third & Fourth Rows: Node Identity === - int textWidth = 0; - int nameX = 0; - int yOffset = (SCREEN_WIDTH > 128) ? 0 : 7; - const char *longName = nullptr; - meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); - if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { - longName = ourNode->user.long_name; - } - uint8_t dmac[6]; - char shortnameble[35]; - getMacAddr(dmac); - snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); - snprintf(shortnameble, sizeof(shortnameble), "%s", haveGlyphs(owner.short_name) ? owner.short_name : ""); - - char combinedName[50]; - snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortnameble); - if (SCREEN_WIDTH - (display->getStringWidth(longName) + display->getStringWidth(shortnameble)) > 10) { - size_t len = strlen(combinedName); - if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) { - combinedName[len - 3] = '\0'; // Remove the last three characters - } - textWidth = display->getStringWidth(combinedName); - nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString( - nameX, - ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine)) + yOffset, - combinedName); - } else { - textWidth = display->getStringWidth(longName); - nameX = (SCREEN_WIDTH - textWidth) / 2; - yOffset = (strcmp(shortnameble, "") == 0) ? 1 : 0; - if (yOffset == 1) { - yOffset = (SCREEN_WIDTH > 128) ? 0 : 7; - } - display->drawString( - nameX, - ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine)) + yOffset, - longName); - - // === Fourth Row: ShortName Centered === - textWidth = display->getStringWidth(shortnameble); - nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, - ((rows == 4) ? compactFourthLine : ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine)), - shortnameble); - } -} - -// **************************** -// * LoRa Focused Screen * -// **************************** -static void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->clear(); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - - // === Header === - graphics::drawCommonHeader(display, x, y); - - // === Draw title (aligned with header baseline) === - const int highlightHeight = FONT_HEIGHT_SMALL - 1; - const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; - const char *titleStr = (SCREEN_WIDTH > 128) ? "LoRa Info" : "LoRa"; - const int centerX = x + SCREEN_WIDTH / 2; - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->setColor(BLACK); - } - - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(centerX, textY, titleStr); - if (config.display.heading_bold) { - display->drawString(centerX + 1, textY, titleStr); - } - display->setColor(WHITE); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - // === First Row: Region / BLE Name === - drawNodes(display, x, compactFirstLine + 3, nodeStatus, 0, true); - - uint8_t dmac[6]; - char shortnameble[35]; - getMacAddr(dmac); - snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); - snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", ourId); - int textWidth = display->getStringWidth(shortnameble); - int nameX = (SCREEN_WIDTH - textWidth); - display->drawString(nameX, compactFirstLine, shortnameble); - - // === Second Row: Radio Preset === - auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false); - char regionradiopreset[25]; - const char *region = myRegion ? myRegion->name : NULL; - snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); - textWidth = display->getStringWidth(regionradiopreset); - nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, compactSecondLine, regionradiopreset); - - // === Third Row: Frequency / ChanNum === - char frequencyslot[35]; - char freqStr[16]; - float freq = RadioLibInterface::instance->getFreq(); - snprintf(freqStr, sizeof(freqStr), "%.3f", freq); - if (config.lora.channel_num == 0) { - snprintf(frequencyslot, sizeof(frequencyslot), "Freq: %s", freqStr); - } else { - snprintf(frequencyslot, sizeof(frequencyslot), "Freq/Chan: %s (%d)", freqStr, config.lora.channel_num); - } - size_t len = strlen(frequencyslot); - if (len >= 4 && strcmp(frequencyslot + len - 4, " (0)") == 0) { - frequencyslot[len - 4] = '\0'; // Remove the last three characters - } - textWidth = display->getStringWidth(frequencyslot); - nameX = (SCREEN_WIDTH - textWidth) / 2; - display->drawString(nameX, compactThirdLine, frequencyslot); - - // === Fourth Row: Channel Utilization === - const char *chUtil = "ChUtil:"; - char chUtilPercentage[10]; - snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); - - int chUtil_x = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; - int chUtil_y = compactFourthLine + 3; - - int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50; - int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7; - int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3; - int chutil_percent = airTime->channelUtilizationPercent(); - - int centerofscreen = SCREEN_WIDTH / 2; - int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; - int starting_position = centerofscreen - total_line_content_width; - - display->drawString(starting_position, compactFourthLine, chUtil); - - // Force 56% or higher to show a full 100% bar, text would still show related percent. - if (chutil_percent >= 61) { - chutil_percent = 100; - } - - // Weighting for nonlinear segments - float milestone1 = 25; - float milestone2 = 40; - float weight1 = 0.45; // Weight for 0–25% - float weight2 = 0.35; // Weight for 25–40% - float weight3 = 0.20; // Weight for 40–100% - float totalWeight = weight1 + weight2 + weight3; - - int seg1 = chutil_bar_width * (weight1 / totalWeight); - int seg2 = chutil_bar_width * (weight2 / totalWeight); - int seg3 = chutil_bar_width * (weight3 / totalWeight); - - int fillRight = 0; - - if (chutil_percent <= milestone1) { - fillRight = (seg1 * (chutil_percent / milestone1)); - } else if (chutil_percent <= milestone2) { - fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); - } else { - fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); - } - - // Draw outline - display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); - - // Fill progress - if (fillRight > 0) { - display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); - } - - display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, compactFourthLine, chUtilPercentage); -} - -// **************************** -// * My Position Screen * -// **************************** -static void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->clear(); - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->setFont(FONT_SMALL); - - // === Header === - graphics::drawCommonHeader(display, x, y); - - // === Draw title === - const int highlightHeight = FONT_HEIGHT_SMALL - 1; - const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; - const char *titleStr = "GPS"; - const int centerX = x + SCREEN_WIDTH / 2; - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->setColor(BLACK); - } - - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(centerX, textY, titleStr); - if (config.display.heading_bold) { - display->drawString(centerX + 1, textY, titleStr); - } - display->setColor(WHITE); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - // === First Row: My Location === -#if HAS_GPS - bool origBold = config.display.heading_bold; - config.display.heading_bold = false; - - String Satelite_String = "Sat:"; - display->drawString(0, ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine), Satelite_String); - String displayLine = ""; - if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - if (config.position.fixed_position) { - displayLine = "Fixed GPS"; - } else { - displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; - } - display->drawString(display->getStringWidth(Satelite_String) + 3, - ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine), displayLine); - } else { - drawGPS(display, display->getStringWidth(Satelite_String) + 3, - ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine) + 3, gpsStatus); - } - - config.display.heading_bold = origBold; - - // === Update GeoCoord === - geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()), - int32_t(gpsStatus->getAltitude())); - - // === Determine Compass Heading === - float heading; - bool validHeading = false; - - if (screen->hasHeading()) { - heading = radians(screen->getHeading()); - validHeading = true; - } else { - heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7); - validHeading = !isnan(heading); - } - - // If GPS is off, no need to display these parts - if (displayLine != "GPS off" && displayLine != "No GPS") { - - // === Second Row: Altitude === - String displayLine; - displayLine = " Alt: " + String(geoCoord.getAltitude()) + "m"; - if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) - displayLine = " Alt: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; - display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine), displayLine); - - // === Third Row: Latitude === - char latStr[32]; - snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7); - display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactThirdLine : moreCompactThirdLine), latStr); - - // === Fourth Row: Longitude === - char lonStr[32]; - snprintf(lonStr, sizeof(lonStr), " Lon: %.5f", geoCoord.getLongitude() * 1e-7); - display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine), lonStr); - - // === Fifth Row: Date === - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); - char datetimeStr[25]; - bool showTime = false; // set to true for full datetime - formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, showTime); - char fullLine[40]; - snprintf(fullLine, sizeof(fullLine), " Date: %s", datetimeStr); - display->drawString(0, ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine), fullLine); - } - - // === Draw Compass if heading is valid === - if (validHeading) { - // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- - if (SCREEN_WIDTH > SCREEN_HEIGHT) { - const int16_t topY = compactFirstLine; - const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height - 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; - - // Center vertically and nudge down slightly to keep "N" clear of header - const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; - - screen->drawNodeHeading(display, compassX, compassY, compassDiam, -heading); - display->drawCircle(compassX, compassY, compassRadius); - - // "N" label - float northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } else { - // Portrait or square: put compass at the bottom and centered, scaled to fit available space - // For E-Ink screens, account for navigation bar at the bottom! - int yBelowContent = ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine) + FONT_HEIGHT_SMALL + 2; - const int margin = 4; - int availableHeight = -#if defined(USE_EINK) - SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink -#else - SCREEN_HEIGHT - yBelowContent - margin; -#endif - - 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; - - screen->drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); - display->drawCircle(compassX, compassY, compassRadius); - - // "N" label - float northAngle = -heading; - float radius = compassRadius; - int16_t nX = compassX + (radius - 1) * sin(northAngle); - int16_t nY = compassY - (radius - 1) * cos(northAngle); - int16_t nLabelWidth = display->getStringWidth("N") + 2; - int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; - - display->setColor(BLACK); - display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); - display->setColor(WHITE); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); - } - } -#endif -} - -// **************************** -// * Memory Screen * -// **************************** -static void drawMemoryScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->clear(); - display->setFont(FONT_SMALL); - display->setTextAlignment(TEXT_ALIGN_LEFT); - - // === Header === - graphics::drawCommonHeader(display, x, y); - - // === Draw title === - const int highlightHeight = FONT_HEIGHT_SMALL - 1; - const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; - const char *titleStr = (SCREEN_WIDTH > 128) ? "Memory" : "Mem"; - const int centerX = x + SCREEN_WIDTH / 2; - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->setColor(BLACK); - } - - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(centerX, textY, titleStr); - if (config.display.heading_bold) { - display->drawString(centerX + 1, textY, titleStr); - } - display->setColor(WHITE); - - // === Layout === - int contentY = y + FONT_HEIGHT_SMALL; - const int rowYOffset = FONT_HEIGHT_SMALL - 3; - const int barHeight = 6; - const int labelX = x; - const int barsOffset = (SCREEN_WIDTH > 128) ? 24 : 0; - const int barX = x + 40 + barsOffset; - - int rowY = contentY; - - // === Heap delta tracking (disabled) === - /* - static uint32_t previousHeapFree = 0; - static int32_t totalHeapDelta = 0; - static int deltaChangeCount = 0; - */ - - auto drawUsageRow = [&](const char *label, uint32_t used, uint32_t total, bool isHeap = false) { - if (total == 0) - return; - - int percent = (used * 100) / total; - - char combinedStr[24]; - if (SCREEN_WIDTH > 128) { - snprintf(combinedStr, sizeof(combinedStr), "%s%3d%% %lu/%luKB", (percent > 80) ? "! " : "", percent, used / 1024, - total / 1024); - } else { - snprintf(combinedStr, sizeof(combinedStr), "%s%3d%%", (percent > 80) ? "! " : "", percent); - } - - int textWidth = display->getStringWidth(combinedStr); - int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6; - if (adjustedBarWidth < 10) - adjustedBarWidth = 10; - - int fillWidth = (used * adjustedBarWidth) / total; - - // Label - display->setTextAlignment(TEXT_ALIGN_LEFT); - display->drawString(labelX, rowY, label); - - // Bar - int barY = rowY + (FONT_HEIGHT_SMALL - barHeight) / 2; - display->setColor(WHITE); - display->drawRect(barX, barY, adjustedBarWidth, barHeight); - - display->fillRect(barX, barY, fillWidth, barHeight); - display->setColor(WHITE); - - // Value string - display->setTextAlignment(TEXT_ALIGN_RIGHT); - display->drawString(SCREEN_WIDTH - 2, rowY, combinedStr); - - rowY += rowYOffset; - - // === Heap delta display (disabled) === - /* - if (isHeap && previousHeapFree > 0) { - int32_t delta = (int32_t)(memGet.getFreeHeap() - previousHeapFree); - if (delta != 0) { - totalHeapDelta += delta; - deltaChangeCount++; - - char deltaStr[16]; - snprintf(deltaStr, sizeof(deltaStr), "%ld", delta); - - int deltaX = centerX - display->getStringWidth(deltaStr) / 2 - 8; - int deltaY = rowY + 1; - - // Triangle - if (delta > 0) { - display->drawLine(deltaX, deltaY + 6, deltaX + 3, deltaY); - display->drawLine(deltaX + 3, deltaY, deltaX + 6, deltaY + 6); - display->drawLine(deltaX, deltaY + 6, deltaX + 6, deltaY + 6); - } else { - display->drawLine(deltaX, deltaY, deltaX + 3, deltaY + 6); - display->drawLine(deltaX + 3, deltaY + 6, deltaX + 6, deltaY); - display->drawLine(deltaX, deltaY, deltaX + 6, deltaY); - } - - display->setTextAlignment(TEXT_ALIGN_CENTER); - display->drawString(centerX + 6, deltaY, deltaStr); - rowY += rowYOffset; - } - } - - if (isHeap) { - previousHeapFree = memGet.getFreeHeap(); - } - */ - }; - - // === Memory values === - uint32_t heapUsed = memGet.getHeapSize() - memGet.getFreeHeap(); - uint32_t heapTotal = memGet.getHeapSize(); - - uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram(); - uint32_t psramTotal = memGet.getPsramSize(); - - uint32_t flashUsed = 0, flashTotal = 0; -#ifdef ESP32 - flashUsed = FSCom.usedBytes(); - flashTotal = FSCom.totalBytes(); -#endif - - uint32_t sdUsed = 0, sdTotal = 0; - bool hasSD = false; - /* - #ifdef HAS_SDCARD - hasSD = SD.cardType() != CARD_NONE; - if (hasSD) { - sdUsed = SD.usedBytes(); - sdTotal = SD.totalBytes(); - } - #endif - */ - // === Draw memory rows - drawUsageRow("Heap:", heapUsed, heapTotal, true); - drawUsageRow("PSRAM:", psramUsed, psramTotal); -#ifdef ESP32 - if (flashTotal > 0) - drawUsageRow("Flash:", flashUsed, flashTotal); -#endif - if (hasSD && sdTotal > 0) - drawUsageRow("SD:", sdUsed, sdTotal); -} - #if defined(ESP_PLATFORM) && defined(USE_ST7789) SPIClass SPI1(HSPI); #endif @@ -3440,7 +825,7 @@ Screen::~Screen() void Screen::doDeepSleep() { #ifdef USE_EINK - setOn(false, drawDeepSleepScreen); + setOn(false, graphics::UIRenderer::drawDeepSleepFrame); #ifdef PIN_EINK_EN digitalWrite(PIN_EINK_EN, LOW); // power off backlight #endif @@ -3535,87 +920,6 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver) screenOn = on; } } -static int8_t lastFrameIndex = -1; -static uint32_t lastFrameChangeTime = 0; -constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; - -void NavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) -{ - int currentFrame = state->currentFrame; - - // Detect frame change and record time - if (currentFrame != lastFrameIndex) { - lastFrameIndex = currentFrame; - lastFrameChangeTime = millis(); - } - - const bool useBigIcons = (SCREEN_WIDTH > 128); - const int iconSize = useBigIcons ? 16 : 8; - const int spacing = useBigIcons ? 8 : 4; - const int bigOffset = useBigIcons ? 1 : 0; - - const size_t totalIcons = screen->indicatorIcons.size(); - if (totalIcons == 0) - return; - - const size_t iconsPerPage = (SCREEN_WIDTH + spacing) / (iconSize + spacing); - const size_t currentPage = currentFrame / iconsPerPage; - const size_t pageStart = currentPage * iconsPerPage; - const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons); - - const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing; - const int xStart = (SCREEN_WIDTH - totalWidth) / 2; - - // Only show bar briefly after switching frames (unless on E-Ink) -#if defined(USE_EINK) - int y = SCREEN_HEIGHT - iconSize - 1; -#else - int y = SCREEN_HEIGHT - iconSize - 1; - if (millis() - lastFrameChangeTime > ICON_DISPLAY_DURATION_MS) { - y = SCREEN_HEIGHT; - } -#endif - - // Pre-calculate bounding rect - const int rectX = xStart - 2 - bigOffset; - const int rectWidth = totalWidth + 4 + (bigOffset * 2); - const int rectHeight = iconSize + 6; - - // Clear background and draw border - display->setColor(BLACK); - display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2); - display->setColor(WHITE); - display->drawRect(rectX, y - 2, rectWidth, rectHeight); - - // Icon drawing loop for the current page - for (size_t i = pageStart; i < pageEnd; ++i) { - const uint8_t *icon = screen->indicatorIcons[i]; - const int x = xStart + (i - pageStart) * (iconSize + spacing); - const bool isActive = (i == static_cast(currentFrame)); - - if (isActive) { - display->setColor(WHITE); - display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4); - display->setColor(BLACK); - } - - if (useBigIcons) { - drawScaledXBitmap16x16(x, y, 8, 8, icon, display); - } else { - display->drawXbm(x, y, iconSize, iconSize, icon); - } - - if (isActive) { - display->setColor(WHITE); - } - } - - // Knock the corners off the square - display->setColor(BLACK); - display->drawRect(rectX, y - 2, 1, 1); - display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1); - display->setColor(WHITE); -} void Screen::setup() { @@ -3651,8 +955,8 @@ void Screen::setup() // === Set custom overlay callbacks === static OverlayCallback overlays[] = { - drawFunctionOverlay, // For mute/buzzer modifiers etc. - NavigationBar // Custom indicator icons for each frame + graphics::UIRenderer::drawFunctionOverlay, // For mute/buzzer modifiers etc. + graphics::UIRenderer::drawNavigationBar // Custom indicator icons for each frame }; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); @@ -3668,12 +972,12 @@ void Screen::setup() alertFrames[0] = [this](OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { #ifdef ARCH_ESP32 if (wakeCause == ESP_SLEEP_WAKEUP_TIMER || wakeCause == ESP_SLEEP_WAKEUP_EXT1) - drawFrameText(display, state, x, y, "Resuming..."); + graphics::UIRenderer::drawFrameText(display, state, x, y, "Resuming..."); else #endif { const char *region = myRegion ? myRegion->name : nullptr; - drawIconScreen(region, display, state, x, y); + graphics::UIRenderer::drawIconScreen(region, display, state, x, y); } }; ui->setFrames(alertFrames, 1); @@ -3810,7 +1114,7 @@ int32_t Screen::runOnce() if (showingOEMBootScreen && (millis() > ((logo_timeout / 2) + serialSinceMsec))) { LOG_INFO("Switch to OEM screen..."); // Change frames. - static FrameCallback bootOEMFrames[] = {drawOEMBootScreen}; + static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen}; static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]); ui->setFrames(bootOEMFrames, bootOEMFrameCount); ui->update(); @@ -3926,31 +1230,13 @@ int32_t Screen::runOnce() return (1000 / targetFramerate); } -void Screen::drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrame(display, state, x, y); -} - -void Screen::drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrameSettings(display, state, x, y); -} - -void Screen::drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - Screen *screen2 = reinterpret_cast(state->userData); - screen2->debugInfo.drawFrameWiFi(display, state, x, y); -} - /* show a message that the SSL cert is being built * it is expected that this will be used during the boot phase */ void Screen::setSSLFrames() { if (address_found.address) { // LOG_DEBUG("Show SSL frames"); - static FrameCallback sslFrames[] = {drawSSLScreen}; + static FrameCallback sslFrames[] = {graphics::NotificationRenderer::NotificationRenderer::drawSSLScreen}; ui->setFrames(sslFrames, 1); ui->update(); } @@ -3962,7 +1248,7 @@ void Screen::setWelcomeFrames() { if (address_found.address) { // LOG_DEBUG("Show Welcome frames"); - static FrameCallback frames[] = {drawWelcomeScreen}; + static FrameCallback frames[] = {graphics::NotificationRenderer::NotificationRenderer::drawWelcomeScreen}; setFrameImmediateDraw(frames); } } @@ -3989,7 +1275,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) // Else, display the usual "overlay" screensaver else { - screensaverOverlay = drawScreensaverOverlay; + screensaverOverlay = graphics::UIRenderer::drawScreensaverOverlay; ui->setOverlays(&screensaverOverlay, 1); } @@ -4081,7 +1367,7 @@ void Screen::setFrames(FrameFocus focus) // If we have a critical fault, show it first fsi.positions.fault = numframes; if (error_code) { - normalFrames[numframes++] = drawCriticalFaultFrame; + normalFrames[numframes++] = graphics::NotificationRenderer::NotificationRenderer::drawCriticalFaultFrame; indicatorIcons.push_back(icon_error); focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame } @@ -4095,49 +1381,49 @@ void Screen::setFrames(FrameFocus focus) if (willInsertTextMessage) { fsi.positions.textMessage = numframes; - normalFrames[numframes++] = drawTextMessageFrame; + normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame; indicatorIcons.push_back(icon_mail); } - normalFrames[numframes++] = drawDeviceFocused; + normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused; indicatorIcons.push_back(icon_home); #ifndef USE_EINK - normalFrames[numframes++] = drawDynamicNodeListScreen; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicNodeListScreen; indicatorIcons.push_back(icon_nodes); #endif // Show detailed node views only on E-Ink builds #ifdef USE_EINK - normalFrames[numframes++] = drawLastHeardScreen; + normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen; indicatorIcons.push_back(icon_nodes); - normalFrames[numframes++] = drawHopSignalScreen; + normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen; indicatorIcons.push_back(icon_signal); - normalFrames[numframes++] = drawDistanceScreen; + normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen; indicatorIcons.push_back(icon_distance); #endif - normalFrames[numframes++] = drawNodeListWithCompasses; + normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses; indicatorIcons.push_back(icon_list); - normalFrames[numframes++] = drawCompassAndLocationScreen; + normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen; indicatorIcons.push_back(icon_compass); - normalFrames[numframes++] = drawLoRaFocused; + normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused; indicatorIcons.push_back(icon_radio); if (!dismissedFrames.memory) { fsi.positions.memory = numframes; - normalFrames[numframes++] = drawMemoryScreen; + normalFrames[numframes++] = graphics::DebugRenderer::drawMemoryUsage; indicatorIcons.push_back(icon_memory); } for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) { meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i); if (n && n->num != nodeDB->getNodeNum() && n->is_favorite) { - normalFrames[numframes++] = drawNodeInfo; + normalFrames[numframes++] = graphics::UIRenderer::drawNodeInfo; indicatorIcons.push_back(icon_node); } } @@ -4147,16 +1433,16 @@ void Screen::setFrames(FrameFocus focus) // Since frames are basic function pointers, we have to use a helper to // call a method on debugInfo object. // fsi.positions.log = numframes; - // normalFrames[numframes++] = &Screen::drawDebugInfoTrampoline; + // normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoTrampoline; // call a method on debugInfoScreen object (for more details) // fsi.positions.settings = numframes; - // normalFrames[numframes++] = &Screen::drawDebugInfoSettingsTrampoline; + // normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoSettingsTrampoline; #if HAS_WIFI && !defined(ARCH_PORTDUINO) if (!dismissedFrames.wifi && isWifiAvailable()) { fsi.positions.wifi = numframes; - normalFrames[numframes++] = &Screen::drawDebugInfoWiFiTrampoline; + normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline; indicatorIcons.push_back(icon_wifi); } #endif @@ -4169,7 +1455,8 @@ void Screen::setFrames(FrameFocus focus) ui->disableAllIndicators(); // Add overlays: frame icons and alert banner) - static OverlayCallback overlays[] = {NavigationBar, drawAlertBannerOverlay}; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, + graphics::NotificationRenderer::NotificationRenderer::drawAlertBannerOverlay}; ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); prevFrame = -1; // Force drawNodeInfo to pick a new node (because our list @@ -4255,7 +1542,7 @@ void Screen::handleStartFirmwareUpdateScreen() showingNormalScreen = false; EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // E-Ink: Explicitly use fast-refresh for next frame - static FrameCallback frames[] = {drawFrameFirmware}; + static FrameCallback frames[] = {graphics::NotificationRenderer::NotificationRenderer::drawFrameFirmware}; setFrameImmediateDraw(frames); } @@ -4322,23 +1609,6 @@ void Screen::removeFunctionSymbol(std::string sym) setFastFramerate(); } -std::string Screen::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds) -{ - 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 Screen::handlePrint(const char *text) { // the string passed into us probably has a newline, but that would confuse the logging system @@ -4402,321 +1672,6 @@ void Screen::setFastFramerate() runASAP = true; } -void DebugInfo::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - char channelStr[20]; - { - concurrency::LockGuard guard(&lock); - snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex())); - } - - // Display power status - if (powerStatus->getHasBattery()) { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawBattery(display, x, y + 2, imgBattery, powerStatus); - } else { - drawBattery(display, x + 1, y + 3, imgBattery, powerStatus); - } - } else if (powerStatus->knowsUSB()) { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); - } else { - display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); - } - } - // Display nodes status - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus); - } else { - drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus); - } -#if HAS_GPS - // Display GPS status - if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - drawGPSpowerstat(display, x, y + 2, gpsStatus); - } else { - if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { - drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus); - } else { - drawGPS(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus); - } - } -#endif - display->setColor(WHITE); - // Draw the channel name - display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr); - // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo - if (moduleConfig.store_forward.enabled) { -#ifdef ARCH_ESP32 - 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(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, - imgQuestionL1); - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, - imgQuestionL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, - imgQuestion); -#endif - } else { -#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ - defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ - !defined(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 16, 8, - imgSFL1); - display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 16, 8, - imgSFL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 11, 8, - imgSF); -#endif - } -#endif - } 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(DISPLAY_FORCE_SMALL_FONTS) - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 12, 8, - imgInfoL1); - display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, - imgInfoL2); -#else - display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, imgInfo); -#endif - } - - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(ourId), y + FONT_HEIGHT_SMALL, ourId); - - // Draw any log messages - display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2)); - - /* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -} - -// Jm -void DebugInfo::drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ -#if HAS_WIFI && !defined(ARCH_PORTDUINO) - const char *wifiName = config.network.wifi_ssid; - - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - if (WiFi.status() != WL_CONNECTED) { - display->drawString(x, y, String("WiFi: Not Connected")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("WiFi: Not Connected")); - } else { - display->drawString(x, y, String("WiFi: Connected")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("WiFi: Connected")); - - display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())), y, - "RSSI " + String(WiFi.RSSI())); - if (config.display.heading_bold) { - display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())) - 1, y, - "RSSI " + String(WiFi.RSSI())); - } - } - - display->setColor(WHITE); - - /* - - WL_CONNECTED: assigned when connected to a WiFi network; - - WL_NO_SSID_AVAIL: assigned when no SSID are available; - - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts; - - WL_CONNECTION_LOST: assigned when the connection is lost; - - WL_DISCONNECTED: assigned when disconnected from a network; - - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of - attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED); - - WL_SCAN_COMPLETED: assigned when the scan networks is completed; - - WL_NO_SHIELD: assigned when no WiFi shield is present; - - */ - if (WiFi.status() == WL_CONNECTED) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "IP: " + String(WiFi.localIP().toString().c_str())); - } else if (WiFi.status() == WL_NO_SSID_AVAIL) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "SSID Not Found"); - } else if (WiFi.status() == WL_CONNECTION_LOST) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Lost"); - } else if (WiFi.status() == WL_CONNECT_FAILED) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Failed"); - } else if (WiFi.status() == WL_IDLE_STATUS) { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Idle ... Reconnecting"); - } -#ifdef ARCH_ESP32 - else { - // Codes: - // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, - WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); - } -#else - else { - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Unkown status: " + String(WiFi.status())); - } -#endif - - display->drawString(x, y + FONT_HEIGHT_SMALL * 2, "SSID: " + String(wifiName)); - - display->drawString(x, y + FONT_HEIGHT_SMALL * 3, "http://meshtastic.local"); - - /* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -#endif -} - -void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) -{ - display->setFont(FONT_SMALL); - - // The coordinates define the left starting point of the text - display->setTextAlignment(TEXT_ALIGN_LEFT); - - if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { - display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); - display->setColor(BLACK); - } - - char batStr[20]; - if (powerStatus->getHasBattery()) { - int batV = powerStatus->getBatteryVoltageMv() / 1000; - int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; - - snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), - powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); - - // Line 1 - display->drawString(x, y, batStr); - if (config.display.heading_bold) - display->drawString(x + 1, y, batStr); - } else { - // Line 1 - display->drawString(x, y, String("USB")); - if (config.display.heading_bold) - display->drawString(x + 1, y, String("USB")); - } - - // auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true); - - // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode); - // if (config.display.heading_bold) - // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode); - - uint32_t currentMillis = millis(); - uint32_t seconds = currentMillis / 1000; - uint32_t minutes = seconds / 60; - uint32_t hours = minutes / 60; - uint32_t days = hours / 24; - // currentMillis %= 1000; - // seconds %= 60; - // minutes %= 60; - // hours %= 24; - - // Show uptime as days, hours, minutes OR seconds - std::string uptime = screen->drawTimeDelta(days, hours, minutes, seconds); - - // Line 1 (Still) - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - if (config.display.heading_bold) - display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - - display->setColor(WHITE); - - // Setup string to assemble analogClock string - std::string analogClock = ""; - - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone - if (rtc_sec > 0) { - long hms = rtc_sec % SEC_PER_DAY; - // hms += tz.tz_dsttime * SEC_PER_HOUR; - // hms -= tz.tz_minuteswest * SEC_PER_MIN; - // mod `hms` to ensure in positive range of [0...SEC_PER_DAY) - hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; - - // Tear apart hms into h:m:s - int hour = hms / SEC_PER_HOUR; - int min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; - int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN - - char timebuf[12]; - - if (config.display.use_12h_clock) { - std::string meridiem = "am"; - if (hour >= 12) { - if (hour > 12) - hour -= 12; - meridiem = "pm"; - } - if (hour == 00) { - hour = 12; - } - snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str()); - } else { - snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec); - } - analogClock += timebuf; - } - - // Line 2 - display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); - - // Display Channel Utilization - char chUtil[13]; - snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); - -#if HAS_GPS - if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { - // Line 3 - if (config.display.gps_format != - meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude - drawGPSAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - - // Line 4 - drawGPScoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); - } else { - drawGPSpowerstat(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); - } -#endif -/* Display a heartbeat pixel that blinks every time the frame is redrawn */ -#ifdef SHOW_REDRAWS - if (heartbeat) - display->setPixel(0, 0); - heartbeat = !heartbeat; -#endif -} - int Screen::handleStatusUpdate(const meshtastic::Status *arg) { // LOG_DEBUG("Screen got status update %d", arg->getStatusType()); diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index e348e7571..29e5df226 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -5,6 +5,10 @@ #include "detect/ScanI2C.h" #include "mesh/generated/meshtastic/config.pb.h" #include +#include +#include + +#define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) #if !HAS_SCREEN #include "power.h" @@ -64,6 +68,7 @@ class Screen #include "mesh/MeshModule.h" #include "power.h" #include +#include // 0 to 255, though particular variants might define different defaults #ifndef BRIGHTNESS_DEFAULT @@ -228,21 +233,11 @@ class Screen : public concurrency::OSThread void blink(); - void drawFrameText(OLEDDisplay *, OLEDDisplayUiState *, int16_t, int16_t, const char *); - void getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength); // Draw north - void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading); - - static uint16_t getCompassDiam(uint32_t displayWidth, uint32_t displayHeight); - float estimatedHeading(double lat, double lon); - void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian); - - void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); - /// Handle button press, trackball or swipe action) void onPress() { enqueueCmd(ScreenCmd{.cmd = Cmd::ON_PRESS}); } void showPrevFrame() { enqueueCmd(ScreenCmd{.cmd = Cmd::SHOW_PREV_FRAME}); } @@ -322,9 +317,6 @@ class Screen : public concurrency::OSThread } } - /// generates a very brief time delta display - std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds); - /// Overrides the default utf8 character conversion, to replace empty space with question marks static char customFontTableLookup(const uint8_t ch) { @@ -643,13 +635,6 @@ class Screen : public concurrency::OSThread // Sets frame up for immediate drawing void setFrameImmediateDraw(FrameCallback *drawFrames); - /// Called when debug screen is to be drawn, calls through to debugInfo.drawFrame. - static void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - - static void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); - #if defined(DISPLAY_CLOCK_FRAME) static void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); @@ -706,4 +691,8 @@ class Screen : public concurrency::OSThread extern String alertBannerMessage; extern uint32_t alertBannerUntil; +// Extern declarations for function symbols used in UIRenderer +extern std::vector functionSymbol; +extern std::string functionSymbolString; + #endif \ No newline at end of file diff --git a/src/graphics/ScreenGlobals.cpp b/src/graphics/ScreenGlobals.cpp new file mode 100644 index 000000000..bc139faaf --- /dev/null +++ b/src/graphics/ScreenGlobals.cpp @@ -0,0 +1,6 @@ +#include +#include + +// Global variables for screen function overlay +std::vector functionSymbol; +std::string functionSymbolString; 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..b55dd5141 --- /dev/null +++ b/src/graphics/draw/CompassRenderer.cpp @@ -0,0 +1,132 @@ +#include "CompassRenderer.h" +#include "NodeDB.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.cpp b/src/graphics/draw/DebugRenderer.cpp new file mode 100644 index 000000000..167fd602d --- /dev/null +++ b/src/graphics/draw/DebugRenderer.cpp @@ -0,0 +1,671 @@ +#include "DebugRenderer.h" +#include "../Screen.h" +#include "FSCommon.h" +#include "NodeDB.h" +#include "Throttle.h" +#include "UIRenderer.h" +#include "airtime.h" +#include "configuration.h" +#include "gps/RTC.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include "mesh/Channels.h" +#include "mesh/generated/meshtastic/deviceonly.pb.h" +#include "sleep.h" + +#if HAS_WIFI && !defined(ARCH_PORTDUINO) +#include "mesh/wifi/WiFiAPClient.h" +#include +#ifdef ARCH_ESP32 +#include "mesh/wifi/WiFiAPClient.h" +#endif +#endif + +#ifdef ARCH_ESP32 +#include "modules/StoreForwardModule.h" +#endif +#include +#include +#include + +using namespace meshtastic; + +// Battery icon array +static uint8_t imgBattery[16] = {0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xE7, 0x3C}; + +// External variables +extern graphics::Screen *screen; +extern PowerStatus *powerStatus; +extern NodeStatus *nodeStatus; +extern GPSStatus *gpsStatus; +extern Channels channels; +extern "C" { +extern char ourId[5]; +} +extern AirTime *airTime; + +// External functions from Screen.cpp +extern bool heartbeat; + +#ifdef ARCH_ESP32 +extern StoreForwardModule *storeForwardModule; +#endif + +namespace graphics +{ +namespace DebugRenderer +{ + +void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setFont(FONT_SMALL); + + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); + display->setColor(BLACK); + } + + char channelStr[20]; + snprintf(channelStr, sizeof(channelStr), "#%s", channels.getName(channels.getPrimaryIndex())); + + // Display power status + if (powerStatus->getHasBattery()) { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawBattery(display, x, y + 2, imgBattery, powerStatus); + } else { + UIRenderer::drawBattery(display, x + 1, y + 3, imgBattery, powerStatus); + } + } else if (powerStatus->knowsUSB()) { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + display->drawFastImage(x, y + 2, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); + } else { + display->drawFastImage(x + 1, y + 3, 16, 8, powerStatus->getHasUSB() ? imgUSB : imgPower); + } + } + // Display nodes status + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 2, nodeStatus); + } else { + UIRenderer::drawNodes(display, x + (SCREEN_WIDTH * 0.25), y + 3, nodeStatus); + } +#if HAS_GPS + // Display GPS status + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + UIRenderer::drawGpsPowerStatus(display, x, y + 2, gpsStatus); + } else { + if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { + UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 2, gpsStatus); + } else { + UIRenderer::drawGps(display, x + (SCREEN_WIDTH * 0.63), y + 3, gpsStatus); + } + } +#endif + display->setColor(WHITE); + // Draw the channel name + display->drawString(x, y + FONT_HEIGHT_SMALL, channelStr); + // Draw our hardware ID to assist with bluetooth pairing. Either prefix with Info or S&F Logo + if (moduleConfig.store_forward.enabled) { +#ifdef ARCH_ESP32 + 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(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); + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, + imgQuestionL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, + imgQuestion); +#endif + } else { +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 3 + FONT_HEIGHT_SMALL, 16, 8, + imgSFL1); + display->drawFastImage(x + SCREEN_WIDTH - 18 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 16, 8, + imgSFL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 13 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 11, 8, + imgSF); +#endif + } +#endif + } 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(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); + display->drawFastImage(x + SCREEN_WIDTH - 14 - display->getStringWidth(ourId), y + 11 + FONT_HEIGHT_SMALL, 12, 8, + imgInfoL2); +#else + display->drawFastImage(x + SCREEN_WIDTH - 10 - display->getStringWidth(ourId), y + 2 + FONT_HEIGHT_SMALL, 8, 8, imgInfo); +#endif + } + + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(ourId), y + FONT_HEIGHT_SMALL, ourId); + + // Draw any log messages + display->drawLogBuffer(x, y + (FONT_HEIGHT_SMALL * 2)); + + /* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +} + +void drawFrameWiFi(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ +#if HAS_WIFI && !defined(ARCH_PORTDUINO) + const char *wifiName = config.network.wifi_ssid; + + display->setFont(FONT_SMALL); + + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); + display->setColor(BLACK); + } + + if (WiFi.status() != WL_CONNECTED) { + display->drawString(x, y, String("WiFi: Not Connected")); + if (config.display.heading_bold) + display->drawString(x + 1, y, String("WiFi: Not Connected")); + } else { + display->drawString(x, y, String("WiFi: Connected")); + if (config.display.heading_bold) + display->drawString(x + 1, y, String("WiFi: Connected")); + + display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())), y, + "RSSI " + String(WiFi.RSSI())); + if (config.display.heading_bold) { + display->drawString(x + SCREEN_WIDTH - display->getStringWidth("RSSI " + String(WiFi.RSSI())) - 1, y, + "RSSI " + String(WiFi.RSSI())); + } + } + + display->setColor(WHITE); + + /* + - WL_CONNECTED: assigned when connected to a WiFi network; + - WL_NO_SSID_AVAIL: assigned when no SSID are available; + - WL_CONNECT_FAILED: assigned when the connection fails for all the attempts; + - WL_CONNECTION_LOST: assigned when the connection is lost; + - WL_DISCONNECTED: assigned when disconnected from a network; + - WL_IDLE_STATUS: it is a temporary status assigned when WiFi.begin() is called and remains active until the number of + attempts expires (resulting in WL_CONNECT_FAILED) or a connection is established (resulting in WL_CONNECTED); + - WL_SCAN_COMPLETED: assigned when the scan networks is completed; + - WL_NO_SHIELD: assigned when no WiFi shield is present; + + */ + if (WiFi.status() == WL_CONNECTED) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "IP: " + String(WiFi.localIP().toString().c_str())); + } else if (WiFi.status() == WL_NO_SSID_AVAIL) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "SSID Not Found"); + } else if (WiFi.status() == WL_CONNECTION_LOST) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Lost"); + } else if (WiFi.status() == WL_IDLE_STATUS) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Idle ... Reconnecting"); + } else if (WiFi.status() == WL_CONNECT_FAILED) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Connection Failed"); + } +#ifdef ARCH_ESP32 + else { + // Codes: + // https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/wifi.html#wi-fi-reason-code + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, + WiFi.disconnectReasonName(static_cast(getWifiDisconnectReason()))); + } +#else + else { + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, "Unkown status: " + String(WiFi.status())); + } +#endif + + display->drawString(x, y + FONT_HEIGHT_SMALL * 2, "SSID: " + String(wifiName)); + + display->drawString(x, y + FONT_HEIGHT_SMALL * 3, "http://meshtastic.local"); + + /* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +#endif +} + +void drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setFont(FONT_SMALL); + + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->fillRect(0 + x, 0 + y, x + display->getWidth(), y + FONT_HEIGHT_SMALL); + display->setColor(BLACK); + } + + char batStr[20]; + if (powerStatus->getHasBattery()) { + int batV = powerStatus->getBatteryVoltageMv() / 1000; + int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + + snprintf(batStr, sizeof(batStr), "B %01d.%02dV %3d%% %c%c", batV, batCv, powerStatus->getBatteryChargePercent(), + powerStatus->getIsCharging() ? '+' : ' ', powerStatus->getHasUSB() ? 'U' : ' '); + + // Line 1 + display->drawString(x, y, batStr); + if (config.display.heading_bold) + display->drawString(x + 1, y, batStr); + } else { + // Line 1 + display->drawString(x, y, String("USB")); + if (config.display.heading_bold) + display->drawString(x + 1, y, String("USB")); + } + + // auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, true); + + // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode), y, mode); + // if (config.display.heading_bold) + // display->drawString(x + SCREEN_WIDTH - display->getStringWidth(mode) - 1, y, mode); + + uint32_t currentMillis = millis(); + uint32_t seconds = currentMillis / 1000; + uint32_t minutes = seconds / 60; + uint32_t hours = minutes / 60; + uint32_t days = hours / 24; + // currentMillis %= 1000; + // seconds %= 60; + // minutes %= 60; + // hours %= 24; + + // Show uptime as days, hours, minutes OR seconds + std::string uptime = UIRenderer::drawTimeDelta(days, hours, minutes, seconds); + + // Line 1 (Still) + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); + if (config.display.heading_bold) + display->drawString(x - 1 + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); + + display->setColor(WHITE); + + // Setup string to assemble analogClock string + std::string analogClock = ""; + + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone + if (rtc_sec > 0) { + long hms = rtc_sec % SEC_PER_DAY; + // hms += tz.tz_dsttime * SEC_PER_HOUR; + // hms -= tz.tz_minuteswest * SEC_PER_MIN; + // mod `hms` to ensure in positive range of [0...SEC_PER_DAY) + hms = (hms + SEC_PER_DAY) % SEC_PER_DAY; + + // Tear apart hms into h:m:s + int hour = hms / SEC_PER_HOUR; + int min = (hms % SEC_PER_HOUR) / SEC_PER_MIN; + int sec = (hms % SEC_PER_HOUR) % SEC_PER_MIN; // or hms % SEC_PER_MIN + + char timebuf[12]; + + if (config.display.use_12h_clock) { + std::string meridiem = "am"; + if (hour >= 12) { + if (hour > 12) + hour -= 12; + meridiem = "pm"; + } + if (hour == 00) { + hour = 12; + } + snprintf(timebuf, sizeof(timebuf), "%d:%02d:%02d%s", hour, min, sec, meridiem.c_str()); + } else { + snprintf(timebuf, sizeof(timebuf), "%02d:%02d:%02d", hour, min, sec); + } + analogClock += timebuf; + } + + // Line 2 + display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); + + // Display Channel Utilization + char chUtil[13]; + snprintf(chUtil, sizeof(chUtil), "ChUtil %2.0f%%", airTime->channelUtilizationPercent()); + display->drawString(x + SCREEN_WIDTH - display->getStringWidth(chUtil), y + FONT_HEIGHT_SMALL * 1, chUtil); + +#if HAS_GPS + if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + // Line 3 + if (config.display.gps_format != + meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) // if DMS then don't draw altitude + UIRenderer::drawGpsAltitude(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); + + // Line 4 + UIRenderer::drawGpsCoordinates(display, x, y + FONT_HEIGHT_SMALL * 3, gpsStatus); + } else { + UIRenderer::drawGpsPowerStatus(display, x, y + FONT_HEIGHT_SMALL * 2, gpsStatus); + } +#endif +/* Display a heartbeat pixel that blinks every time the frame is redrawn */ +#ifdef SHOW_REDRAWS + if (heartbeat) + display->setPixel(0, 0); + heartbeat = !heartbeat; +#endif +} + +// Trampoline functions for DebugInfo class access +void drawDebugInfoTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrame(display, state, x, y); +} + +void drawDebugInfoSettingsTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrameSettings(display, state, x, y); +} + +void drawDebugInfoWiFiTrampoline(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + drawFrameWiFi(display, state, x, y); +} + +// **************************** +// * LoRa Focused Screen * +// **************************** +void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // === Header === + graphics::drawCommonHeader(display, x, y); + + // === Draw title (aligned with header baseline) === + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + const char *titleStr = (SCREEN_WIDTH > 128) ? "LoRa Info" : "LoRa"; + const int centerX = x + SCREEN_WIDTH / 2; + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->setColor(BLACK); + } + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, textY, titleStr); + if (config.display.heading_bold) { + display->drawString(centerX + 1, textY, titleStr); + } + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === First Row: Region / BLE Name === + graphics::UIRenderer::drawNodes(display, x, compactFirstLine + 3, nodeStatus, 0, true); + + uint8_t dmac[6]; + char shortnameble[35]; + getMacAddr(dmac); + snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); + snprintf(shortnameble, sizeof(shortnameble), "BLE: %s", ourId); + int textWidth = display->getStringWidth(shortnameble); + int nameX = (SCREEN_WIDTH - textWidth); + display->drawString(nameX, compactFirstLine, shortnameble); + + // === Second Row: Radio Preset === + auto mode = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false); + char regionradiopreset[25]; + const char *region = myRegion ? myRegion->name : NULL; + snprintf(regionradiopreset, sizeof(regionradiopreset), "%s/%s", region, mode); + textWidth = display->getStringWidth(regionradiopreset); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, compactSecondLine, regionradiopreset); + + // === Third Row: Frequency / ChanNum === + char frequencyslot[35]; + char freqStr[16]; + float freq = RadioLibInterface::instance->getFreq(); + snprintf(freqStr, sizeof(freqStr), "%.3f", freq); + if (config.lora.channel_num == 0) { + snprintf(frequencyslot, sizeof(frequencyslot), "Freq: %s", freqStr); + } else { + snprintf(frequencyslot, sizeof(frequencyslot), "Freq/Chan: %s (%d)", freqStr, config.lora.channel_num); + } + size_t len = strlen(frequencyslot); + if (len >= 4 && strcmp(frequencyslot + len - 4, " (0)") == 0) { + frequencyslot[len - 4] = '\0'; // Remove the last three characters + } + textWidth = display->getStringWidth(frequencyslot); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, compactThirdLine, frequencyslot); + + // === Fourth Row: Channel Utilization === + const char *chUtil = "ChUtil:"; + char chUtilPercentage[10]; + snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent()); + + int chUtil_x = (SCREEN_WIDTH > 128) ? display->getStringWidth(chUtil) + 10 : display->getStringWidth(chUtil) + 5; + int chUtil_y = compactFourthLine + 3; + + int chutil_bar_width = (SCREEN_WIDTH > 128) ? 100 : 50; + int chutil_bar_height = (SCREEN_WIDTH > 128) ? 12 : 7; + int extraoffset = (SCREEN_WIDTH > 128) ? 6 : 3; + int chutil_percent = airTime->channelUtilizationPercent(); + + int centerofscreen = SCREEN_WIDTH / 2; + int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2; + int starting_position = centerofscreen - total_line_content_width; + + display->drawString(starting_position, compactFourthLine, chUtil); + + // Force 56% or higher to show a full 100% bar, text would still show related percent. + if (chutil_percent >= 61) { + chutil_percent = 100; + } + + // Weighting for nonlinear segments + float milestone1 = 25; + float milestone2 = 40; + float weight1 = 0.45; // Weight for 0–25% + float weight2 = 0.35; // Weight for 25–40% + float weight3 = 0.20; // Weight for 40–100% + float totalWeight = weight1 + weight2 + weight3; + + int seg1 = chutil_bar_width * (weight1 / totalWeight); + int seg2 = chutil_bar_width * (weight2 / totalWeight); + int seg3 = chutil_bar_width * (weight3 / totalWeight); + + int fillRight = 0; + + if (chutil_percent <= milestone1) { + fillRight = (seg1 * (chutil_percent / milestone1)); + } else if (chutil_percent <= milestone2) { + fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1))); + } else { + fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2))); + } + + // Draw outline + display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height); + + // Fill progress + if (fillRight > 0) { + display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height); + } + + display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, compactFourthLine, chUtilPercentage); +} + +// **************************** +// * Memory Screen * +// **************************** +void drawMemoryUsage(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === Header === + graphics::drawCommonHeader(display, x, y); + + // === Draw title === + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + const char *titleStr = (SCREEN_WIDTH > 128) ? "Memory" : "Mem"; + const int centerX = x + SCREEN_WIDTH / 2; + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->setColor(BLACK); + } + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, textY, titleStr); + if (config.display.heading_bold) { + display->drawString(centerX + 1, textY, titleStr); + } + display->setColor(WHITE); + + // === Layout === + int contentY = y + FONT_HEIGHT_SMALL; + const int rowYOffset = FONT_HEIGHT_SMALL - 3; + const int barHeight = 6; + const int labelX = x; + const int barsOffset = (SCREEN_WIDTH > 128) ? 24 : 0; + const int barX = x + 40 + barsOffset; + + int rowY = contentY; + + // === Heap delta tracking (disabled) === + /* + static uint32_t previousHeapFree = 0; + static int32_t totalHeapDelta = 0; + static int deltaChangeCount = 0; + */ + + auto drawUsageRow = [&](const char *label, uint32_t used, uint32_t total, bool isHeap = false) { + if (total == 0) + return; + + int percent = (used * 100) / total; + + char combinedStr[24]; + if (SCREEN_WIDTH > 128) { + snprintf(combinedStr, sizeof(combinedStr), "%s%3d%% %lu/%luKB", (percent > 80) ? "! " : "", percent, used / 1024, + total / 1024); + } else { + snprintf(combinedStr, sizeof(combinedStr), "%s%3d%%", (percent > 80) ? "! " : "", percent); + } + + int textWidth = display->getStringWidth(combinedStr); + int adjustedBarWidth = SCREEN_WIDTH - barX - textWidth - 6; + if (adjustedBarWidth < 10) + adjustedBarWidth = 10; + + int fillWidth = (used * adjustedBarWidth) / total; + + // Label + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawString(labelX, rowY, label); + + // Bar + int barY = rowY + (FONT_HEIGHT_SMALL - barHeight) / 2; + display->setColor(WHITE); + display->drawRect(barX, barY, adjustedBarWidth, barHeight); + + display->fillRect(barX, barY, fillWidth, barHeight); + display->setColor(WHITE); + + // Value string + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(SCREEN_WIDTH - 2, rowY, combinedStr); + + rowY += rowYOffset; + + // === Heap delta display (disabled) === + /* + if (isHeap && previousHeapFree > 0) { + int32_t delta = (int32_t)(memGet.getFreeHeap() - previousHeapFree); + if (delta != 0) { + totalHeapDelta += delta; + deltaChangeCount++; + + char deltaStr[16]; + snprintf(deltaStr, sizeof(deltaStr), "%ld", delta); + + int deltaX = centerX - display->getStringWidth(deltaStr) / 2 - 8; + int deltaY = rowY + 1; + + // Triangle + if (delta > 0) { + display->drawLine(deltaX, deltaY + 6, deltaX + 3, deltaY); + display->drawLine(deltaX + 3, deltaY, deltaX + 6, deltaY + 6); + display->drawLine(deltaX, deltaY + 6, deltaX + 6, deltaY + 6); + } else { + display->drawLine(deltaX, deltaY, deltaX + 3, deltaY + 6); + display->drawLine(deltaX + 3, deltaY + 6, deltaX + 6, deltaY); + display->drawLine(deltaX, deltaY, deltaX + 6, deltaY); + } + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX + 6, deltaY, deltaStr); + rowY += rowYOffset; + } + } + + if (isHeap) { + previousHeapFree = memGet.getFreeHeap(); + } + */ + }; + + // === Memory values === + uint32_t heapUsed = memGet.getHeapSize() - memGet.getFreeHeap(); + uint32_t heapTotal = memGet.getHeapSize(); + + uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram(); + uint32_t psramTotal = memGet.getPsramSize(); + + uint32_t flashUsed = 0, flashTotal = 0; +#ifdef ESP32 + flashUsed = FSCom.usedBytes(); + flashTotal = FSCom.totalBytes(); +#endif + + uint32_t sdUsed = 0, sdTotal = 0; + bool hasSD = false; + /* + #ifdef HAS_SDCARD + hasSD = SD.cardType() != CARD_NONE; + if (hasSD) { + sdUsed = SD.usedBytes(); + sdTotal = SD.totalBytes(); + } + #endif + */ + // === Draw memory rows + drawUsageRow("Heap:", heapUsed, heapTotal, true); + drawUsageRow("PSRAM:", psramUsed, psramTotal); +#ifdef ESP32 + if (flashTotal > 0) + drawUsageRow("Flash:", flashUsed, flashTotal); +#endif + if (hasSD && sdTotal > 0) + drawUsageRow("SD:", sdUsed, sdTotal); +} +} // namespace DebugRenderer +} // namespace graphics diff --git a/src/graphics/draw/DebugRenderer.h b/src/graphics/draw/DebugRenderer.h new file mode 100644 index 000000000..f4d484f58 --- /dev/null +++ b/src/graphics/draw/DebugRenderer.h @@ -0,0 +1,38 @@ +#pragma once + +#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 +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 framework callback compatibility +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); + +// LoRa information display +void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Memory screen display +void drawMemoryUsage(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..a61f40ffc --- /dev/null +++ b/src/graphics/draw/MessageRenderer.cpp @@ -0,0 +1,448 @@ +/* +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; +} + +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount) +{ + int cursorX = x; + const int fontHeight = FONT_HEIGHT_SMALL; + + // === Step 1: Find tallest emote in the line === + int maxIconHeight = fontHeight; + for (size_t i = 0; i < line.length();) { + bool matched = false; + for (int e = 0; e < emoteCount; ++e) { + size_t emojiLen = strlen(emotes[e].label); + if (line.compare(i, emojiLen, emotes[e].label) == 0) { + if (emotes[e].height > maxIconHeight) + maxIconHeight = emotes[e].height; + i += emojiLen; + matched = true; + break; + } + } + if (!matched) { + uint8_t c = static_cast(line[i]); + if ((c & 0xE0) == 0xC0) + i += 2; + else if ((c & 0xF0) == 0xE0) + i += 3; + else if ((c & 0xF8) == 0xF0) + i += 4; + else + i += 1; + } + } + + // === Step 2: Baseline alignment === + int lineHeight = std::max(fontHeight, maxIconHeight); + int baselineOffset = (lineHeight - fontHeight) / 2; + int fontY = y + baselineOffset; + int fontMidline = fontY + fontHeight / 2; + + // === Step 3: Render line in segments === + size_t i = 0; + bool inBold = false; + + while (i < line.length()) { + // Check for ** start/end for faux bold + if (line.compare(i, 2, "**") == 0) { + inBold = !inBold; + i += 2; + continue; + } + + // Look ahead for the next emote match + size_t nextEmotePos = std::string::npos; + const Emote *matchedEmote = nullptr; + size_t emojiLen = 0; + + for (int e = 0; e < emoteCount; ++e) { + size_t pos = line.find(emotes[e].label, i); + if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) { + nextEmotePos = pos; + matchedEmote = &emotes[e]; + emojiLen = strlen(emotes[e].label); + } + } + + // Render normal text segment up to the emote or bold toggle + size_t nextControl = std::min(nextEmotePos, line.find("**", i)); + if (nextControl == std::string::npos) + nextControl = line.length(); + + if (nextControl > i) { + std::string textChunk = line.substr(i, nextControl - i); + if (inBold) { + // Faux bold: draw twice, offset by 1px + display->drawString(cursorX + 1, fontY, textChunk.c_str()); + } + display->drawString(cursorX, fontY, textChunk.c_str()); + cursorX += display->getStringWidth(textChunk.c_str()); + i = nextControl; + continue; + } + + // Render the emote (if found) + if (matchedEmote && i == nextEmotePos) { + int iconY = fontMidline - matchedEmote->height / 2 - 1; + display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap); + cursorX += matchedEmote->width + 1; + i += emojiLen; + } else { + // No more emotes — render the rest of the line + std::string remaining = line.substr(i); + if (inBold) { + display->drawString(cursorX + 1, fontY, remaining.c_str()); + } + display->drawString(cursorX, fontY, remaining.c_str()); + cursorX += display->getStringWidth(remaining.c_str()); + break; + } + } +} + +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", UIRenderer::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); + } + } + } +} + +} // namespace MessageRenderer +} // namespace graphics diff --git a/src/graphics/draw/MessageRenderer.h b/src/graphics/draw/MessageRenderer.h new file mode 100644 index 000000000..d92b96014 --- /dev/null +++ b/src/graphics/draw/MessageRenderer.h @@ -0,0 +1,18 @@ +#pragma once +#include "OLEDDisplay.h" +#include "OLEDDisplayUi.h" +#include "graphics/emotes.h" + +namespace graphics +{ +namespace MessageRenderer +{ + +// Text and emote rendering +void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount); + +/// 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..7bf742f4f --- /dev/null +++ b/src/graphics/draw/NodeListRenderer.cpp @@ -0,0 +1,877 @@ +#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 : 3), 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 ? 17 : 25) : (isLeftCol ? 13 : 17); + + int barsXOffset = columnWidth - barsOffset; + + String nodeName = getSafeNodeName(node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + display->drawStringMaxWidth(x + ((SCREEN_WIDTH > 128) ? 6 : 3), 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 + 1 + (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 (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 : 3), 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); + } +} + +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); + + // 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 : 3), 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); +} + +// ============================= +// 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); + } + } +} + +/// Draw a series of fields in a column, wrapping to multiple columns if needed +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields) +{ + // The coordinates define the left starting point of the text + display->setTextAlignment(TEXT_ALIGN_LEFT); + + const char **f = fields; + int xo = x, yo = y; + while (*f) { + display->drawString(xo, yo, *f); + if ((display->getColor() == BLACK) && config.display.heading_bold) + display->drawString(xo + 1, yo, *f); + + display->setColor(WHITE); + yo += FONT_HEIGHT_SMALL; + if (yo > SCREEN_HEIGHT - FONT_HEIGHT_SMALL) { + xo += SCREEN_WIDTH / 2; + yo = 0; + } + f++; + } +} + +} // namespace NodeListRenderer +} // namespace graphics diff --git a/src/graphics/draw/NodeListRenderer.h b/src/graphics/draw/NodeListRenderer.h new file mode 100644 index 000000000..d35350cb8 --- /dev/null +++ b/src/graphics/draw/NodeListRenderer.h @@ -0,0 +1,71 @@ +#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); +void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields); + +// 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/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp new file mode 100644 index 000000000..6d8423adc --- /dev/null +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -0,0 +1,177 @@ +#include "NotificationRenderer.h" +#include "DisplayFormatters.h" +#include "NodeDB.h" +#include "configuration.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include +#include +#include + +#ifdef ARCH_ESP32 +#include "esp_task_wdt.h" +#endif + +using namespace meshtastic; + +// External references to global variables from Screen.cpp +extern String alertBannerMessage; +extern uint32_t alertBannerUntil; +extern std::vector functionSymbol; +extern std::string functionSymbolString; +extern bool hasUnreadMessage; + +namespace graphics +{ + +namespace NotificationRenderer +{ + +// Used on boot when a certificate is being created +void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_SMALL); + display->drawString(64 + x, y, "Creating SSL certificate"); + +#ifdef ARCH_ESP32 + yield(); + esp_task_wdt_reset(); +#endif + + display->setFont(FONT_SMALL); + if ((millis() / 1000) % 2) { + display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); + } else { + display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); + } +} + +// Used when booting without a region set +void NotificationRenderer::drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(64 + x, y, "//\\ E S H T /\\ S T / C"); + display->drawString(64 + x, y + FONT_HEIGHT_SMALL, getDeviceName()); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + if ((millis() / 10000) % 2) { + display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Set the region using the"); + display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "Meshtastic Android, iOS,"); + display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, "Web or CLI clients."); + } else { + display->drawString(x, y + FONT_HEIGHT_SMALL * 2 - 3, "Visit meshtastic.org"); + display->drawString(x, y + FONT_HEIGHT_SMALL * 3 - 3, "for more information."); + display->drawString(x, y + FONT_HEIGHT_SMALL * 4 - 3, ""); + } + +#ifdef ARCH_ESP32 + yield(); + esp_task_wdt_reset(); +#endif +} + +void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + // Exit if no message is active or duration has passed + if (alertBannerMessage.length() == 0 || (alertBannerUntil != 0 && millis() > alertBannerUntil)) + return; + + // === Layout Configuration === + constexpr uint16_t padding = 5; // Padding around text inside the box + constexpr uint8_t lineSpacing = 1; // Extra space between lines + + // Search the message to determine if we need the bell added + bool needs_bell = (alertBannerMessage.indexOf("Alert Received") != -1); + + // Setup font and alignment + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); // We will manually center per line + + // === Split the message into lines (supports multi-line banners) === + std::vector lines; + int start = 0, newlineIdx; + while ((newlineIdx = alertBannerMessage.indexOf('\n', start)) != -1) { + lines.push_back(alertBannerMessage.substring(start, newlineIdx)); + start = newlineIdx + 1; + } + lines.push_back(alertBannerMessage.substring(start)); + + // === Measure text dimensions === + uint16_t minWidth = (SCREEN_WIDTH > 128) ? 106 : 78; + uint16_t maxWidth = 0; + std::vector lineWidths; + for (const auto &line : lines) { + uint16_t w = display->getStringWidth(line.c_str(), line.length(), true); + lineWidths.push_back(w); + if (w > maxWidth) + maxWidth = w; + } + + uint16_t boxWidth = padding * 2 + maxWidth; + if (needs_bell && boxWidth < minWidth) + boxWidth += (SCREEN_WIDTH > 128) ? 26 : 20; + + uint16_t boxHeight = padding * 2 + lines.size() * FONT_HEIGHT_SMALL + (lines.size() - 1) * lineSpacing; + + int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); + int16_t boxTop = (display->height() / 2) - (boxHeight / 2); + + // === Draw background box === + display->setColor(BLACK); + display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Slightly oversized box + display->setColor(WHITE); + display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Border + + // === Draw each line centered in the box === + int16_t lineY = boxTop + padding; + for (size_t i = 0; i < lines.size(); ++i) { + int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; + uint16_t line_width = display->getStringWidth(lines[i].c_str(), lines[i].length(), true); + + if (needs_bell && i == 0) { + int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2; + display->drawXbm(textX - 10, bellY, 8, 8, bell_alert); + display->drawXbm(textX + line_width + 2, bellY, 8, 8, bell_alert); + } + + display->drawString(textX, lineY, lines[i]); + if (SCREEN_WIDTH > 128) + display->drawString(textX + 1, lineY, lines[i]); // Faux bold + + lineY += FONT_HEIGHT_SMALL + lineSpacing; + } +} + +/// Draw the last text message we received +void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_MEDIUM); + + char tempBuf[24]; + snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); + display->drawString(0 + x, 0 + y, tempBuf); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); +} + +void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(64 + x, y, "Updating"); + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), + "Please be patient and do not power off."); +} + +} // namespace NotificationRenderer + +} // namespace graphics diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h new file mode 100644 index 000000000..6f07d75c4 --- /dev/null +++ b/src/graphics/draw/NotificationRenderer.h @@ -0,0 +1,24 @@ +#pragma once + +#include "OLEDDisplay.h" +#include "OLEDDisplayUi.h" + +namespace graphics +{ + +namespace NotificationRenderer +{ + +class NotificationRenderer +{ + public: + static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); + static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawWelcomeScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + static void drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +}; + +} // namespace NotificationRenderer + +} // namespace graphics diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp new file mode 100644 index 000000000..3564db51a --- /dev/null +++ b/src/graphics/draw/UIRenderer.cpp @@ -0,0 +1,1254 @@ +#include "UIRenderer.h" +#include "CompassRenderer.h" +#include "GPSStatus.h" +#include "NodeDB.h" +#include "NodeListRenderer.h" +#include "configuration.h" +#include "gps/GeoCoord.h" +#include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" +#include "graphics/SharedUIDisplay.h" +#include "graphics/images.h" +#include "main.h" +#include "target_specific.h" +#include +#include + +#if !MESHTASTIC_EXCLUDE_GPS + +// External variables +extern graphics::Screen *screen; +extern "C" { +extern char ourId[5]; +} + +namespace graphics +{ + +// GeoCoord object for coordinate conversions +extern GeoCoord geoCoord; + +// Threshold values for the GPS lock accuracy bar display +extern uint32_t dopThresholds[5]; + +namespace UIRenderer +{ + +// Draw GPS status summary +void drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + if (config.position.fixed_position) { + // GPS coordinates are currently fixed + display->drawString(x - 1, y - 2, "Fixed GPS"); + if (config.display.heading_bold) + display->drawString(x, y - 2, "Fixed GPS"); + return; + } + if (!gps->getIsConnected()) { + display->drawString(x, y - 2, "No GPS"); + if (config.display.heading_bold) + display->drawString(x + 1, y - 2, "No GPS"); + return; + } + // Adjust position if we're going to draw too wide + int maxDrawWidth = 6; // Position icon + + if (!gps->getHasLock()) { + maxDrawWidth += display->getStringWidth("No sats") + 2; // icon + text + buffer + } else { + maxDrawWidth += (5 * 2) + 8 + display->getStringWidth("99") + 2; // bars + sat icon + text + buffer + } + + if (x + maxDrawWidth > display->getWidth()) { + x = display->getWidth() - maxDrawWidth; + if (x < 0) + x = 0; // Clamp to screen + } + + display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty); + if (!gps->getHasLock()) { + // Draw "No sats" to the right of the icon with slightly more gap + int textX = x + 9; // 6 (icon) + 3px spacing + display->drawString(textX, y - 3, "No sats"); + if (config.display.heading_bold) + display->drawString(textX + 1, y - 3, "No sats"); + return; + } else { + char satsString[3]; + uint8_t bar[2] = {0}; + + // Draw DOP signal bars + for (int i = 0; i < 5; i++) { + if (gps->getDOP() <= dopThresholds[i]) + bar[0] = ~((1 << (5 - i)) - 1); + else + bar[0] = 0b10000000; + + display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); + } + + // Draw satellite image + display->drawFastImage(x + 24, y, 8, 8, imgSatellite); + + // Draw the number of satellites + snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); + int textX = x + 34; + display->drawString(textX, y - 2, satsString); + if (config.display.heading_bold) + display->drawString(textX + 1, y - 2, satsString); + } +} + +// Draw status when GPS is disabled or not present +void drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + String displayLine; + int pos; + if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + pos = display->getWidth() - display->getStringWidth(displayLine); + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present" + : "GPS is disabled"; + pos = (display->getWidth() - display->getStringWidth(displayLine)) / 2; + } + display->drawString(x + pos, y, displayLine); +} + +void drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + String displayLine = ""; + if (!gps->getIsConnected() && !config.position.fixed_position) { + // displayLine = "No GPS Module"; + // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else if (!gps->getHasLock() && !config.position.fixed_position) { + // displayLine = "No GPS Lock"; + // display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else { + geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); + displayLine = "Altitude: " + String(geoCoord.getAltitude()) + "m"; + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) + displayLine = "Altitude: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } +} + +// Draw GPS status coordinates +void drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps) +{ + auto gpsFormat = config.display.gps_format; + String displayLine = ""; + + if (!gps->getIsConnected() && !config.position.fixed_position) { + displayLine = "No GPS present"; + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else if (!gps->getHasLock() && !config.position.fixed_position) { + displayLine = "No GPS Lock"; + display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine); + } else { + + geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude())); + + if (gpsFormat != meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DMS) { + char coordinateLine[22]; + if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees + snprintf(coordinateLine, sizeof(coordinateLine), "%f %f", geoCoord.getLatitude() * 1e-7, + geoCoord.getLongitude() * 1e-7); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator + snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %06u %07u", geoCoord.getUTMZone(), geoCoord.getUTMBand(), + geoCoord.getUTMEasting(), geoCoord.getUTMNorthing()); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System + snprintf(coordinateLine, sizeof(coordinateLine), "%2i%1c %1c%1c %05u %05u", geoCoord.getMGRSZone(), + geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k(), + geoCoord.getMGRSEasting(), geoCoord.getMGRSNorthing()); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OLC) { // Open Location Code + geoCoord.getOLCCode(coordinateLine); + } else if (gpsFormat == meshtastic_Config_DisplayConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference + if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') // OSGR is only valid around the UK region + snprintf(coordinateLine, sizeof(coordinateLine), "%s", "Out of Boundary"); + else + snprintf(coordinateLine, sizeof(coordinateLine), "%1c%1c %05u %05u", geoCoord.getOSGRE100k(), + geoCoord.getOSGRN100k(), geoCoord.getOSGREasting(), geoCoord.getOSGRNorthing()); + } + + // If fixed position, display text "Fixed GPS" alternating with the coordinates. + if (config.position.fixed_position) { + if ((millis() / 10000) % 2) { + display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y, + coordinateLine); + } else { + display->drawString(x + (display->getWidth() - (display->getStringWidth("Fixed GPS"))) / 2, y, "Fixed GPS"); + } + } else { + display->drawString(x + (display->getWidth() - (display->getStringWidth(coordinateLine))) / 2, y, coordinateLine); + } + } else { + char latLine[22]; + char lonLine[22]; + snprintf(latLine, sizeof(latLine), "%2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(), geoCoord.getDMSLatMin(), + geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP()); + snprintf(lonLine, sizeof(lonLine), "%3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(), geoCoord.getDMSLonMin(), + geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP()); + display->drawString(x + (display->getWidth() - (display->getStringWidth(latLine))) / 2, y - FONT_HEIGHT_SMALL * 1, + latLine); + display->drawString(x + (display->getWidth() - (display->getStringWidth(lonLine))) / 2, y, lonLine); + } + } +} + +// Draw nodes status +void drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset, + bool show_total, String additional_words) +{ + char usersString[20]; + int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0; + + snprintf(usersString, sizeof(usersString), "%d", nodes_online); + + if (show_total) { + int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0; + snprintf(usersString, sizeof(usersString), "%d/%d", nodes_online, nodes_total); + } + +#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \ + defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS)) && \ + !defined(DISPLAY_FORCE_SMALL_FONTS) + display->drawFastImage(x, y + 3, 8, 8, imgUser); +#else + display->drawFastImage(x, y + 1, 8, 8, imgUser); +#endif + display->drawString(x + 10, y - 2, usersString); + int string_offset = (SCREEN_WIDTH > 128) ? 2 : 1; + if (additional_words != "") { + display->drawString(x + 10 + display->getStringWidth(usersString) + string_offset, y - 2, additional_words); + if (config.display.heading_bold) + display->drawString(x + 11 + display->getStringWidth(usersString) + string_offset, y - 2, additional_words); + } +} + +// ********************** +// * Favorite Node Info * +// ********************** +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 YOUR MACROS ===== + // 1. Each potential info row has a macro-defined Y position (not regular increments!). + // 2. Each row is only shown if it has valid data. + // 3. Each row "moves up" if previous are empty, so there are never any blank rows. + // 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot. + + // List of available macro Y positions in order, from top to bottom. + const int yPositions[5] = {moreCompactFirstLine, moreCompactSecondLine, moreCompactThirdLine, moreCompactFourthLine, + moreCompactFifthLine}; + int line = 0; // which slot to use next + + // === 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) { + // Print node's long name (e.g. "Backpack Node") + display->drawString(x, yPositions[line++], username); + } + + // === 2. Signal and Hops (combined on one line, if available) === + // If both are present: "Sig: 97% [2hops]" + // If only one: show only that one + char signalHopsStr[32] = ""; + bool haveSignal = false; + int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100); + + // Always use "Sig" for the label + const char *signalLabel = " Sig"; + + // --- Build the Signal/Hops line --- + // 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); + // Decide between "1 Hop" and "N Hops" + 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; + // Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago" + 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; + // Show as "Up: 2d 3h", "Up: 5h 14m", or "Up: 37m" + if (days) + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %ud %uh", days, hours); + else if (hours) + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %uh %um", hours, mins); + else + snprintf(uptimeStr, sizeof(uptimeStr), " Uptime: %um", 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] = ""; // Make buffer big enough for any string + 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; + + 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 > 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 { + if (distanceKm < 1.0) { + int meters = (int)(distanceKm * 1000); + if (meters > 0 && meters < 1000) { + snprintf(distStr, sizeof(distStr), " Distance: %dm", meters); + haveDistance = true; + } else if (meters >= 1000) { + snprintf(distStr, sizeof(distStr), " Distance: 1km"); + 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: landscape (wide) screens use the original side-aligned logic --- + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + 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 d = + GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + 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 show nothing + } else { + // Portrait or square: put compass at the bottom and centered, scaled to fit available space + 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; +// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) ----------- +#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; + // --------- END PATCH FOR EINK NAV BAR ----------- + + 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)); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading); + + const auto &p = node->position; + float d = + GeoCoord::latLongToMeter(DegD(p.latitude_i), DegD(p.longitude_i), DegD(op.latitude_i), DegD(op.longitude_i)); + 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; + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing); + + display->drawCircle(compassX, compassY, compassRadius); + } + // else show nothing + } +} + +// **************************** +// * Device Focused Screen * +// **************************** +void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // === Header === + graphics::drawCommonHeader(display, x, y); + + // === Content below header === + + // Determine if we need to show 4 or 5 rows on the screen + int rows = 4; + if (!config.bluetooth.enabled) { + rows = 5; + } + + // === First Row: Region / Channel Utilization and Uptime === + bool origBold = config.display.heading_bold; + config.display.heading_bold = false; + + // Display Region and Channel Utilization + drawNodes(display, x + 1, + ((rows == 4) ? compactFirstLine : ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine)) + 2, nodeStatus, + -1, false, "online"); + + uint32_t uptime = millis() / 1000; + char uptimeStr[6]; + uint32_t minutes = uptime / 60, hours = minutes / 60, days = hours / 24; + + if (days > 365) { + snprintf(uptimeStr, sizeof(uptimeStr), "?"); + } else { + snprintf(uptimeStr, sizeof(uptimeStr), "%d%c", + days ? days + : hours ? hours + : minutes ? minutes + : (int)uptime, + days ? 'd' + : hours ? 'h' + : minutes ? 'm' + : 's'); + } + + char uptimeFullStr[16]; + snprintf(uptimeFullStr, sizeof(uptimeFullStr), "Uptime: %s", uptimeStr); + display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeFullStr), + ((rows == 4) ? compactFirstLine : ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine)), + uptimeFullStr); + + config.display.heading_bold = origBold; + + // === Second Row: Satellites and Voltage === + config.display.heading_bold = false; + +#if HAS_GPS + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + String displayLine = ""; + if (config.position.fixed_position) { + displayLine = "Fixed GPS"; + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + } + display->drawString( + 0, ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), + displayLine); + } else { + UIRenderer::drawGps( + display, 0, + ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)) + 3, + gpsStatus); + } +#endif + + char batStr[20]; + if (powerStatus->getHasBattery()) { + int batV = powerStatus->getBatteryVoltageMv() / 1000; + int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv); + display->drawString( + x + SCREEN_WIDTH - display->getStringWidth(batStr), + ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), batStr); + } else { + display->drawString( + x + SCREEN_WIDTH - display->getStringWidth("USB"), + ((rows == 4) ? compactSecondLine : ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine)), + String("USB")); + } + + config.display.heading_bold = origBold; + + // === Third Row: Bluetooth Off (Only If Actually Off) === + if (!config.bluetooth.enabled) { + display->drawString( + 0, ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactThirdLine : moreCompactThirdLine)), "BT off"); + } + + // === Third & Fourth Rows: Node Identity === + int textWidth = 0; + int nameX = 0; + int yOffset = (SCREEN_WIDTH > 128) ? 0 : 7; + const char *longName = nullptr; + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { + longName = ourNode->user.long_name; + } + uint8_t dmac[6]; + char shortnameble[35]; + getMacAddr(dmac); + snprintf(ourId, sizeof(ourId), "%02x%02x", dmac[4], dmac[5]); + snprintf(shortnameble, sizeof(shortnameble), "%s", + graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + + char combinedName[50]; + snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortnameble); + if (SCREEN_WIDTH - (display->getStringWidth(longName) + display->getStringWidth(shortnameble)) > 10) { + size_t len = strlen(combinedName); + if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) { + combinedName[len - 3] = '\0'; // Remove the last three characters + } + textWidth = display->getStringWidth(combinedName); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString( + nameX, + ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine)) + yOffset, + combinedName); + } else { + textWidth = display->getStringWidth(longName); + nameX = (SCREEN_WIDTH - textWidth) / 2; + yOffset = (strcmp(shortnameble, "") == 0) ? 1 : 0; + if (yOffset == 1) { + yOffset = (SCREEN_WIDTH > 128) ? 0 : 7; + } + display->drawString( + nameX, + ((rows == 4) ? compactThirdLine : ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine)) + yOffset, + longName); + + // === Fourth Row: ShortName Centered === + textWidth = display->getStringWidth(shortnameble); + nameX = (SCREEN_WIDTH - textWidth) / 2; + display->drawString(nameX, + ((rows == 4) ? compactFourthLine : ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine)), + shortnameble); + } +} + +// Start Functions to write date/time to the screen +// Helper function to check if a year is a leap year +bool isLeapYear(int year) +{ + return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); +} + +// Array of days in each month (non-leap year) +const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + +// Fills the buffer with a formatted date/time string and returns pixel width +int formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime) +{ + int sec = rtc_sec % 60; + rtc_sec /= 60; + int min = rtc_sec % 60; + rtc_sec /= 60; + int hour = rtc_sec % 24; + rtc_sec /= 24; + + int year = 1970; + while (true) { + int daysInYear = isLeapYear(year) ? 366 : 365; + if (rtc_sec >= (uint32_t)daysInYear) { + rtc_sec -= daysInYear; + year++; + } else { + break; + } + } + + int month = 0; + while (month < 12) { + int dim = daysInMonth[month]; + if (month == 1 && isLeapYear(year)) + dim++; + if (rtc_sec >= (uint32_t)dim) { + rtc_sec -= dim; + month++; + } else { + break; + } + } + + int day = rtc_sec + 1; + + if (includeTime) { + snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec); + } else { + snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day); + } + + return display->getStringWidth(buf); +} + +// Check if the display can render a string (detect special chars; emoji) +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 + return true; +#endif + + // Check each character with the lookup function for the OLED library + // We're not really meant to use this directly.. + bool have = true; + for (uint16_t i = 0; i < strlen(str); i++) { + uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]); + // If font doesn't support a character, it is substituted for ¿ + if (result == 191 && (uint8_t)str[i] != 191) { + have = false; + break; + } + } + + // LOG_DEBUG("haveGlyphs=%d", have); + return have; +} + +#ifdef USE_EINK +/// Used on eink displays while in deep sleep +void drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + + // Next frame should use full-refresh, and block while running, else device will sleep before async callback + EINK_ADD_FRAMEFLAG(display, COSMETIC); + EINK_ADD_FRAMEFLAG(display, BLOCKING); + + LOG_DEBUG("Draw deep sleep screen"); + + // Display displayStr on the screen + graphics::UIRenderer::drawIconScreen("Sleeping", display, state, x, y); +} + +/// Used on eink displays when screen updates are paused +void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + LOG_DEBUG("Draw screensaver overlay"); + + EINK_ADD_FRAMEFLAG(display, COSMETIC); // Take the opportunity for a full-refresh + + // Config + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *pauseText = "Screen Paused"; + const char *idText = owner.short_name; + const bool useId = haveGlyphs(idText); // This bool is used to hide the idText box if we can't render the short name + constexpr uint16_t padding = 5; + constexpr uint8_t dividerGap = 1; + constexpr uint8_t imprecision = 5; // How far the box origins can drift from center. Combat burn-in. + + // Dimensions + const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true); // "true": handle utf8 chars + const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText)); + const uint16_t boxWidth = padding + (useId ? idTextWidth + padding + padding : 0) + pauseTextWidth + padding; + const uint16_t boxHeight = padding + FONT_HEIGHT_SMALL + padding; + + // Position + const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2) + random(-imprecision, imprecision + 1); + // const int16_t boxRight = boxLeft + boxWidth - 1; + const int16_t boxTop = (display->height() / 2) - (boxHeight / 2 + random(-imprecision, imprecision + 1)); + const int16_t boxBottom = boxTop + boxHeight - 1; + const int16_t idTextLeft = boxLeft + padding; + const int16_t idTextTop = boxTop + padding; + const int16_t pauseTextLeft = boxLeft + (useId ? padding + idTextWidth + padding : 0) + padding; + const int16_t pauseTextTop = boxTop + padding; + const int16_t dividerX = boxLeft + padding + idTextWidth + padding; + const int16_t dividerTop = boxTop + 1 + dividerGap; + const int16_t dividerBottom = boxBottom - 1 - dividerGap; + + // Draw: box + display->setColor(EINK_WHITE); + display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Clear a slightly oversized area for the box + display->setColor(EINK_BLACK); + display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); + + // Draw: Text + if (useId) + display->drawString(idTextLeft, idTextTop, idText); + display->drawString(pauseTextLeft, pauseTextTop, pauseText); + display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold + + // Draw: divider + if (useId) + display->drawLine(dividerX, dividerTop, dividerX, dividerBottom); +} +#endif + +/** + * Draw the icon with extra info printed around the corners + */ +void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + const char *label = "BaseUI"; + display->setFont(FONT_SMALL); + int textWidth = display->getStringWidth(label); + int r = 3; // corner radius + + if (SCREEN_WIDTH > 128) { + // === ORIGINAL WIDE SCREEN LAYOUT (unchanged) === + int padding = 4; + int boxWidth = max(icon_width, textWidth) + (padding * 2) + 16; + int boxHeight = icon_height + FONT_HEIGHT_SMALL + (padding * 3) - 8; + int boxX = x - 1 + (SCREEN_WIDTH - boxWidth) / 2; + int boxY = y - 6 + (SCREEN_HEIGHT - boxHeight) / 2; + + display->setColor(WHITE); + display->fillRect(boxX + r, boxY, boxWidth - 2 * r, boxHeight); + display->fillRect(boxX, boxY + r, boxWidth - 1, boxHeight - 2 * r); + display->fillCircle(boxX + r, boxY + r, r); // Upper Left + display->fillCircle(boxX + boxWidth - r - 1, boxY + r, r); // Upper Right + display->fillCircle(boxX + r, boxY + boxHeight - r - 1, r); // Lower Left + display->fillCircle(boxX + boxWidth - r - 1, boxY + boxHeight - r - 1, r); // Lower Right + + display->setColor(BLACK); + int iconX = boxX + (boxWidth - icon_width) / 2; + int iconY = boxY + padding - 2; + display->drawXbm(iconX, iconY, icon_width, icon_height, icon_bits); + + int labelY = iconY + icon_height + padding; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + SCREEN_WIDTH / 2 - 3, labelY, label); + display->drawString(x + SCREEN_WIDTH / 2 - 2, labelY, label); // faux bold + + } else { + // === TIGHT SMALL SCREEN LAYOUT === + int iconY = y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2; + iconY -= 4; + + int labelY = iconY + icon_height - 2; + + int boxWidth = max(icon_width, textWidth) + 4; + int boxX = x + (SCREEN_WIDTH - boxWidth) / 2; + int boxY = iconY - 1; + int boxBottom = labelY + FONT_HEIGHT_SMALL - 2; + int boxHeight = boxBottom - boxY; + + display->setColor(WHITE); + display->fillRect(boxX + r, boxY, boxWidth - 2 * r, boxHeight); + display->fillRect(boxX, boxY + r, boxWidth - 1, boxHeight - 2 * r); + display->fillCircle(boxX + r, boxY + r, r); + display->fillCircle(boxX + boxWidth - r - 1, boxY + r, r); + display->fillCircle(boxX + r, boxY + boxHeight - r - 1, r); + display->fillCircle(boxX + boxWidth - r - 1, boxY + boxHeight - r - 1, r); + + display->setColor(BLACK); + int iconX = boxX + (boxWidth - icon_width) / 2; + display->drawXbm(iconX, iconY, icon_width, icon_height, icon_bits); + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(x + SCREEN_WIDTH / 2, labelY, label); + } + + // === Footer and headers (shared) === + display->setFont(FONT_MEDIUM); + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *title = "meshtastic.org"; + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + + display->setFont(FONT_SMALL); + if (upperMsg) + display->drawString(x + 0, y + 0, upperMsg); + + char buf[25]; + snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), + graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : ""); + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + SCREEN_WIDTH, y + 0, buf); + + screen->forceDisplay(); + display->setTextAlignment(TEXT_ALIGN_LEFT); +} + +// **************************** +// * My Position Screen * +// **************************** +void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + // === Header === + graphics::drawCommonHeader(display, x, y); + + // === Draw title === + const int highlightHeight = FONT_HEIGHT_SMALL - 1; + const int textY = y + 1 + (highlightHeight - FONT_HEIGHT_SMALL) / 2; + const char *titleStr = "GPS"; + const int centerX = x + SCREEN_WIDTH / 2; + + if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED) { + display->setColor(BLACK); + } + + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(centerX, textY, titleStr); + if (config.display.heading_bold) { + display->drawString(centerX + 1, textY, titleStr); + } + display->setColor(WHITE); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + // === First Row: My Location === +#if HAS_GPS + bool origBold = config.display.heading_bold; + config.display.heading_bold = false; + + String Satelite_String = "Sat:"; + display->drawString(0, ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine), Satelite_String); + String displayLine = ""; + if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + if (config.position.fixed_position) { + displayLine = "Fixed GPS"; + } else { + displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + } + display->drawString(display->getStringWidth(Satelite_String) + 3, + ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine), displayLine); + } else { + UIRenderer::drawGps(display, display->getStringWidth(Satelite_String) + 3, + ((SCREEN_HEIGHT > 64) ? compactFirstLine : moreCompactFirstLine) + 3, gpsStatus); + } + + config.display.heading_bold = origBold; + + // === Update GeoCoord === + geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()), + int32_t(gpsStatus->getAltitude())); + + // === Determine Compass Heading === + float heading; + bool validHeading = false; + + if (screen->hasHeading()) { + heading = radians(screen->getHeading()); + validHeading = true; + } else { + heading = screen->estimatedHeading(geoCoord.getLatitude() * 1e-7, geoCoord.getLongitude() * 1e-7); + validHeading = !isnan(heading); + } + + // If GPS is off, no need to display these parts + if (displayLine != "GPS off" && displayLine != "No GPS") { + + // === Second Row: Altitude === + String displayLine; + displayLine = " Alt: " + String(geoCoord.getAltitude()) + "m"; + if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) + displayLine = " Alt: " + String(geoCoord.getAltitude() * METERS_TO_FEET) + "ft"; + display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactSecondLine : moreCompactSecondLine), displayLine); + + // === Third Row: Latitude === + char latStr[32]; + snprintf(latStr, sizeof(latStr), " Lat: %.5f", geoCoord.getLatitude() * 1e-7); + display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactThirdLine : moreCompactThirdLine), latStr); + + // === Fourth Row: Longitude === + char lonStr[32]; + snprintf(lonStr, sizeof(lonStr), " Lon: %.5f", geoCoord.getLongitude() * 1e-7); + display->drawString(x, ((SCREEN_HEIGHT > 64) ? compactFourthLine : moreCompactFourthLine), lonStr); + + // === Fifth Row: Date === + uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); + char datetimeStr[25]; + bool showTime = false; // set to true for full datetime + UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, showTime); + char fullLine[40]; + snprintf(fullLine, sizeof(fullLine), " Date: %s", datetimeStr); + display->drawString(0, ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine), fullLine); + } + + // === Draw Compass if heading is valid === + if (validHeading) { + // --- Compass Rendering: landscape (wide) screens use original side-aligned logic --- + if (SCREEN_WIDTH > SCREEN_HEIGHT) { + const int16_t topY = compactFirstLine; + const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height + 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; + + // Center vertically and nudge down slightly to keep "N" clear of header + const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2; + + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading); + display->drawCircle(compassX, compassY, compassRadius); + + // "N" label + float northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } else { + // Portrait or square: put compass at the bottom and centered, scaled to fit available space + // For E-Ink screens, account for navigation bar at the bottom! + int yBelowContent = ((SCREEN_HEIGHT > 64) ? compactFifthLine : moreCompactFifthLine) + FONT_HEIGHT_SMALL + 2; + const int margin = 4; + int availableHeight = +#if defined(USE_EINK) + SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink +#else + SCREEN_HEIGHT - yBelowContent - margin; +#endif + + 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; + + CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading); + display->drawCircle(compassX, compassY, compassRadius); + + // "N" label + float northAngle = -heading; + float radius = compassRadius; + int16_t nX = compassX + (radius - 1) * sin(northAngle); + int16_t nY = compassY - (radius - 1) * cos(northAngle); + int16_t nLabelWidth = display->getStringWidth("N") + 2; + int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1; + + display->setColor(BLACK); + display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox); + display->setColor(WHITE); + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N"); + } + } +#endif +} + +#ifdef USERPREFS_OEM_TEXT + +void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA; + display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2, + y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH, + USERPREFS_OEM_IMAGE_HEIGHT, xbm); + + switch (USERPREFS_OEM_FONT_SIZE) { + case 0: + display->setFont(FONT_SMALL); + break; + case 2: + display->setFont(FONT_LARGE); + break; + default: + display->setFont(FONT_MEDIUM); + break; + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + const char *title = USERPREFS_OEM_TEXT; + display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title); + display->setFont(FONT_SMALL); + + // Draw region in upper left + if (upperMsg) + display->drawString(x + 0, y + 0, upperMsg); + + // Draw version and shortname in upper right + char buf[25]; + snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : ""); + + display->setTextAlignment(TEXT_ALIGN_RIGHT); + display->drawString(x + SCREEN_WIDTH, y + 0, buf); + screen->forceDisplay(); + + display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code +} + +void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) +{ + // Draw region in upper left + const char *region = myRegion ? myRegion->name : NULL; + drawOEMIconScreen(region, display, state, x, y); +} + +#endif + +// Function overlay for showing mute/buzzer modifiers etc. +void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + // LOG_DEBUG("Draw function overlay"); + if (functionSymbol.begin() != functionSymbol.end()) { + char buf[64]; + display->setFont(FONT_SMALL); + snprintf(buf, sizeof(buf), "%s", functionSymbolString.c_str()); + display->drawString(SCREEN_WIDTH - display->getStringWidth(buf), SCREEN_HEIGHT - FONT_HEIGHT_SMALL, buf); + } +} + +// Navigation bar overlay implementation +static int8_t lastFrameIndex = -1; +static uint32_t lastFrameChangeTime = 0; +constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000; + +void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state) +{ + int currentFrame = state->currentFrame; + + // Detect frame change and record time + if (currentFrame != lastFrameIndex) { + lastFrameIndex = currentFrame; + lastFrameChangeTime = millis(); + } + + const bool useBigIcons = (SCREEN_WIDTH > 128); + const int iconSize = useBigIcons ? 16 : 8; + const int spacing = useBigIcons ? 8 : 4; + const int bigOffset = useBigIcons ? 1 : 0; + + const size_t totalIcons = screen->indicatorIcons.size(); + if (totalIcons == 0) + return; + + const size_t iconsPerPage = (SCREEN_WIDTH + spacing) / (iconSize + spacing); + const size_t currentPage = currentFrame / iconsPerPage; + const size_t pageStart = currentPage * iconsPerPage; + const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons); + + const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing; + const int xStart = (SCREEN_WIDTH - totalWidth) / 2; + + // Only show bar briefly after switching frames (unless on E-Ink) +#if defined(USE_EINK) + int y = SCREEN_HEIGHT - iconSize - 1; +#else + int y = SCREEN_HEIGHT - iconSize - 1; + if (millis() - lastFrameChangeTime > ICON_DISPLAY_DURATION_MS) { + y = SCREEN_HEIGHT; + } +#endif + + // Pre-calculate bounding rect + const int rectX = xStart - 2 - bigOffset; + const int rectWidth = totalWidth + 4 + (bigOffset * 2); + const int rectHeight = iconSize + 6; + + // Clear background and draw border + display->setColor(BLACK); + display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2); + display->setColor(WHITE); + display->drawRect(rectX, y - 2, rectWidth, rectHeight); + + // Icon drawing loop for the current page + for (size_t i = pageStart; i < pageEnd; ++i) { + const uint8_t *icon = screen->indicatorIcons[i]; + const int x = xStart + (i - pageStart) * (iconSize + spacing); + const bool isActive = (i == static_cast(currentFrame)); + + if (isActive) { + display->setColor(WHITE); + display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4); + display->setColor(BLACK); + } + + if (useBigIcons) { + NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display); + } else { + display->drawXbm(x, y, iconSize, iconSize, icon); + } + + if (isActive) { + display->setColor(WHITE); + } + } + + // Knock the corners off the square + display->setColor(BLACK); + display->drawRect(rectX, y - 2, 1, 1); + display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1); + display->setColor(WHITE); +} + +void drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message) +{ + uint16_t x_offset = display->width() / 2; + display->setTextAlignment(TEXT_ALIGN_CENTER); + display->setFont(FONT_MEDIUM); + display->drawString(x_offset + x, 26 + y, message); +} + +std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds) +{ + 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; +} + +} // namespace UIRenderer +} // namespace graphics + +#endif // !MESHTASTIC_EXCLUDE_GPS diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h new file mode 100644 index 000000000..727d5e134 --- /dev/null +++ b/src/graphics/draw/UIRenderer.h @@ -0,0 +1,92 @@ +#pragma once + +#include "graphics/Screen.h" +#include "graphics/emotes.h" +#include +#include +#include + +#define HOURS_IN_MONTH 730 + +// 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 drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gpsStatus); + +// Layout and utility functions +void drawScrollbar(OLEDDisplay *display, int visibleItems, int totalItems, int scrollIndex, int x, int startY); + +// Overlay and special screens +void drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *text); + +// Function overlay for showing mute/buzzer modifiers etc. +void drawFunctionOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); + +// Navigation bar overlay +void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state); + +void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Icon and screen drawing functions +void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// Compass and location screen +void drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +// OEM screens +#ifdef USERPREFS_OEM_TEXT +void drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +void drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); +#endif + +#ifdef USE_EINK +/// Used on eink displays while in deep sleep +void drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); + +/// Used on eink displays when screen updates are paused +void drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); +#endif + +// 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); +int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime); + +// Message filtering +bool shouldDrawMessage(const meshtastic_MeshPacket *packet); +// Check if the display can render a string (detect special chars; emoji) +bool haveGlyphs(const char *str); +} // namespace UIRenderer + +} // namespace graphics diff --git a/src/modules/WaypointModule.cpp b/src/modules/WaypointModule.cpp index 9313c3425..8641990f7 100644 --- a/src/modules/WaypointModule.cpp +++ b/src/modules/WaypointModule.cpp @@ -2,9 +2,12 @@ #include "NodeDB.h" #include "PowerFSM.h" #include "configuration.h" +#include "graphics/draw/CompassRenderer.h" + #if HAS_SCREEN #include "gps/RTC.h" #include "graphics/Screen.h" +#include "graphics/draw/NodeListRenderer.h" #include "main.h" #endif @@ -119,7 +122,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // Dimensions / co-ordinates for the compass/circle int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = graphics::Screen::getCompassDiam(display->getWidth(), display->getHeight()); + uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(display->getWidth(), display->getHeight()); if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { compassX = x + display->getWidth() - compassDiam / 2 - 5; @@ -137,7 +140,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, myHeading = (screen->getHeading()) * PI / 180; // gotta convert compass degrees to Radians else myHeading = screen->estimatedHeading(DegD(op.latitude_i), DegD(op.longitude_i)); - screen->drawCompassNorth(display, compassX, compassY, myHeading); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading); // Compass bearing to waypoint float bearingToOther = @@ -146,7 +149,7 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, // If the top of the compass is not a static north we need adjust bearingToOther based on heading if (!config.display.compass_north_top) bearingToOther -= myHeading; - screen->drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); + graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearingToOther); float bearingToOtherDegrees = (bearingToOther < 0) ? bearingToOther + 2 * PI : bearingToOther; bearingToOtherDegrees = bearingToOtherDegrees * 180 / PI; @@ -189,6 +192,6 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, } // Must be after distStr is populated - screen->drawColumns(display, x, y, fields); + graphics::NodeListRenderer::drawColumns(display, x, y, fields); } #endif diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp index 840d64277..438fd4f7a 100755 --- a/src/motion/MotionSensor.cpp +++ b/src/motion/MotionSensor.cpp @@ -1,4 +1,5 @@ #include "MotionSensor.h" +#include "graphics/draw/CompassRenderer.h" #if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C @@ -48,7 +49,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState display->drawString(x, y + 40, timeRemainingBuffer); int16_t compassX = 0, compassY = 0; - uint16_t compassDiam = graphics::Screen::getCompassDiam(display->getWidth(), display->getHeight()); + uint16_t compassDiam = graphics::CompassRenderer::getCompassDiam(display->getWidth(), display->getHeight()); // coordinates for the center of the compass/circle if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT) { @@ -59,7 +60,7 @@ void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState compassY = y + FONT_HEIGHT_SMALL + (display->getHeight() - FONT_HEIGHT_SMALL) / 2; } display->drawCircle(compassX, compassY, compassDiam / 2); - screen->drawCompassNorth(display, compassX, compassY, screen->getHeading() * PI / 180); + graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, screen->getHeading() * PI / 180); } #endif