diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 0c18f3287..aa56f5c17 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -1130,11 +1130,27 @@ static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus 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()) { - display->drawString(x + 8, y - 2, "No sats"); + // 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 - 2, "No sats"); if (config.display.heading_bold) - display->drawString(x + 9, y - 2, "No sats"); + display->drawString(textX + 1, y - 2, "No sats"); return; } else { char satsString[3]; @@ -1146,7 +1162,7 @@ static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus bar[0] = ~((1 << (5 - i)) - 1); else bar[0] = 0b10000000; - // bar[1] = bar[0]; + display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); } @@ -1155,12 +1171,14 @@ static void drawGPS(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus // Draw the number of satellites snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); - display->drawString(x + 34, y - 2, satsString); + int textX = x + 34; + display->drawString(textX, y - 2, satsString); if (config.display.heading_bold) - display->drawString(x + 35, y - 2, satsString); + 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) { @@ -1294,6 +1312,11 @@ 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) { + Serial.print("πŸ”„ [Node Heading] Raw Bearing (rad): "); + Serial.print(headingRadian); + Serial.print(" | (deg): "); + Serial.println(headingRadian * RAD_TO_DEG); + 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); @@ -1312,6 +1335,10 @@ void Screen::drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t com display->drawLine(leftArrow.x, leftArrow.y, tail.x, tail.y); display->drawLine(rightArrow.x, rightArrow.y, tail.x, tail.y); */ + Serial.print("πŸ”₯ Arrow Tail X: "); Serial.print(tail.x); + Serial.print(" | Y: "); Serial.print(tail.y); + Serial.print(" | Tip X: "); Serial.print(tip.x); + Serial.print(" | Tip Y: "); Serial.println(tip.y); #ifdef USE_EINK display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); #else @@ -1352,6 +1379,9 @@ void Screen::getTimeAgoStr(uint32_t agoSecs, char *timeStr, uint8_t maxLength) 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; @@ -1369,13 +1399,10 @@ void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t co rosePoints[i]->scale(compassDiam); rosePoints[i]->translate(compassX, compassY); } - - /* changed the N sign to a small circle on the compass circle. - display->drawLine(N1.x, N1.y, N3.x, N3.y); - display->drawLine(N2.x, N2.y, N4.x, N4.y); - display->drawLine(N1.x, N1.y, N4.x, N4.y); - */ display->drawCircle(NC1.x, NC1.y, 4); // North sign circle, 4px radius is sufficient for all displays. + Serial.print("πŸ”₯ North Marker X: "); Serial.print(NC1.x); + Serial.print(" | Y: "); Serial.println(NC1.y); + } uint16_t Screen::getCompassDiam(uint32_t displayWidth, uint32_t displayHeight) @@ -1521,6 +1548,476 @@ static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_ // Must be after distStr is populated screen->drawColumns(display, x, y, fields); } +void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r) { + // Center rectangles + display->fillRect(x + r, y, w - 2 * r, h); + display->fillRect(x, y + r, r, h - 2 * r); + display->fillRect(x + w - r, y + r, r, h - 2 * r); + + // Rounded corners + display->fillCircle(x + r, y + r, r); // Top-left + display->fillCircle(x + w - r - 1, y + r, r); // Top-right + display->fillCircle(x + r, y + h - r - 1, r); // Bottom-left + display->fillCircle(x + w - r - 1, y + h - r - 1, r); // Bottom-right +} +// Each node entry holds a reference to its info and how long ago it was heard from +struct NodeEntry { + meshtastic_NodeInfoLite *node; + uint32_t lastHeard; +}; + +// 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Β° +} + +// Grabs all nodes from the DB and sorts them (favorites and most recently heard first) +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; // Skip self + nodeList.push_back({node, sinceLastSeen(node)}); + } + + 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; + }); +} + +// Helper: Fallback-NodeID if emote is on ShortName for display purposes +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 { + // fallback: last 4 hex digits of node ID, no prefix + char idStr[6]; + snprintf(idStr, sizeof(idStr), "%04X", (uint16_t)(node->num & 0xFFFF)); + nodeName = String(idStr); + } + } + + if (node->is_favorite) nodeName = "*" + nodeName; + return nodeName; +} + +// Draws the top header bar (optionally inverted or bold) +void drawScreenHeader(OLEDDisplay *display, const char *title, int16_t x, int16_t y) { + bool isInverted = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); + bool isBold = config.display.heading_bold; + + display->setFont(FONT_SMALL); + display->setTextAlignment(TEXT_ALIGN_LEFT); + + int screenWidth = display->getWidth(); + int textWidth = display->getStringWidth(title); + int titleX = (screenWidth - textWidth) / 2; // Centered X position + + if (isInverted) { + drawRoundedHighlight(display, 0, y, screenWidth, FONT_HEIGHT_SMALL - 2, 2); // Full width from 0 + display->setColor(BLACK); + } + // Fake bold by drawing again with slight offset + display->drawString(titleX, y, title); + if (isBold) display->drawString(titleX + 1, y, title); + + display->setColor(WHITE); +} + +// Draws separator line +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 - 3); +} + +// Draws node name with how long ago it was last heard from +void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { + int screenWidth = display->getWidth(); + bool isLeftCol = (x < screenWidth / 2); + + // Adjust offset based on column and screen width + int timeOffset = (screenWidth > 128) ? (isLeftCol ? 41 : 45) : (isLeftCol ? 24 : 30);//offset large screen (?Left:Right column), offset small screen (?Left: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, y, nodeName); + display->drawString(x + columnWidth - timeOffset, y, timeStr); +} +// Draws each node's name, hop count, and signal bars +void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { + int screenWidth = display->getWidth(); + bool isLeftCol = (x < screenWidth / 2); + + int nameMaxWidth = columnWidth - 25; + int barsOffset = (screenWidth > 128) ? (isLeftCol ? 26 : 30) : (isLeftCol ? 17 : 19);//offset large screen (?Left:Right column), offset small screen (?Left:Right column) + int hopOffset = (screenWidth > 128) ? (isLeftCol ? 32 : 38) : (isLeftCol ? 18 : 20);//offset large screen (?Left:Right column), offset small screen (?Left:Right column) + + int barsXOffset = columnWidth - barsOffset; + + String nodeName = getSafeNodeName(node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x, y, nameMaxWidth, nodeName); + + 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 hopX = x + columnWidth - hopOffset - display->getStringWidth(hopStr); + display->drawString(hopX, y, hopStr); + } + + // Signal bars based on SNR + int bars = (node->snr > 5) ? 4 : (node->snr > 0) ? 3 : (node->snr > -5) ? 2 : (node->snr > -10) ? 1 : 0; + int barWidth = 2; + int barStartX = x + barsXOffset; + int barStartY = y + (FONT_HEIGHT_SMALL / 2) + 2; + + for (int b = 0; b < 4; b++) { + if (b < bars) { + int height = 2 + (b * 2); + display->fillRect(barStartX + (b * (barWidth + 1)), barStartY - height, barWidth, height); + } + } +} + +// Typedef for passing different render functions into one reusable screen function +typedef void (*EntryRenderer)(OLEDDisplay*, meshtastic_NodeInfoLite*, int16_t, int16_t, int); + +// Shared function that renders all node screens (LastHeard, Hop/Signal) +void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, EntryRenderer renderer) { + int columnWidth = display->getWidth() / 2; + int yOffset = FONT_HEIGHT_SMALL - 3; + int col = 0, lastNodeY = y; + + display->clear(); + drawScreenHeader(display, title, x, y); + + std::vector nodeList; + retrieveAndSortNodes(nodeList); + + for (const auto &entry : nodeList) { + int xPos = x + (col * columnWidth); + renderer(display, entry.node, xPos, y + yOffset, columnWidth); + lastNodeY = std::max(lastNodeY, y + yOffset + FONT_HEIGHT_SMALL); + yOffset += FONT_HEIGHT_SMALL - 3; + + if (y + yOffset > display->getHeight() - FONT_HEIGHT_SMALL) { + yOffset = FONT_HEIGHT_SMALL - 3; + col++; + if (col > 1) break; + } + } + // Draw separator between columns + drawColumnSeparator(display, x, y + FONT_HEIGHT_SMALL - 2, lastNodeY); +} + +// Public screen function: shows how recently nodes were heard +static void drawLastHeardScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + drawNodeListScreen(display, state, x, y, "Node List", drawEntryLastHeard); +} + +// Public screen function: shows hop count + signal strength +static void drawHopSignalScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + drawNodeListScreen(display, state, x, y, "Hops/Signal", drawEntryHopSignal); +} + + + + + +// 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) { + int screenWidth = display->getWidth(); + bool isLeftCol = (x < screenWidth / 2); + + // Adjust max text width depending on column and screen width + int nameMaxWidth = columnWidth - (screenWidth > 128 ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22)); + + String nodeName = getSafeNodeName(node); + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x, y, nameMaxWidth, nodeName); +} + +// Extra compass element drawer (injects compass arrows) +typedef void (*CompassExtraRenderer)(OLEDDisplay*, meshtastic_NodeInfoLite*, int16_t, int16_t, int columnWidth, float myHeading, double userLat, double userLon); + +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; + + int screenWidth = display->getWidth(); + bool isLeftCol = (x < screenWidth / 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 arrowAngle = relativeBearing * DEG_TO_RAD; + + // Adaptive offset for compass icon based on screen width + column + int arrowXOffset = (screenWidth > 128) ? (isLeftCol ? 22 : 24) : (isLeftCol ? 12 : 18); + + int compassX = x + columnWidth - arrowXOffset; + int compassY = y + FONT_HEIGHT_SMALL / 2; + int size = FONT_HEIGHT_SMALL / 2 - 2; + int arrowLength = size - 2; + + int xEnd = compassX + arrowLength * cos(arrowAngle); + int yEnd = compassY - arrowLength * sin(arrowAngle); + + display->fillCircle(compassX, compassY, size); + display->drawCircle(compassX, compassY, size); + display->drawLine(compassX, compassY, xEnd, yEnd); +} + +// Generic node+compass renderer (like drawNodeListScreen but with compass support) +void drawNodeListWithExtrasScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *title, + EntryRenderer renderer, CompassExtraRenderer extras) { + int columnWidth = display->getWidth() / 2; + int yOffset = FONT_HEIGHT_SMALL - 3; + int col = 0, lastNodeY = y; + + display->clear(); + drawScreenHeader(display, title, x, y); + + std::vector nodeList; + retrieveAndSortNodes(nodeList); + + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + double userLat = 0.0, userLon = 0.0; + bool hasUserPosition = nodeDB->hasValidPosition(ourNode); + if (hasUserPosition) { + userLat = ourNode->position.latitude_i * 1e-7; + userLon = ourNode->position.longitude_i * 1e-7; + } + + float myHeading = screen->hasHeading() ? screen->getHeading() : 0.0f; + + for (const auto &entry : nodeList) { + int xPos = x + (col * columnWidth); + renderer(display, entry.node, xPos, y + yOffset, columnWidth); + + if (hasUserPosition && extras) { + extras(display, entry.node, xPos, y + yOffset, columnWidth, myHeading, userLat, userLon); + } + + lastNodeY = std::max(lastNodeY, y + yOffset + FONT_HEIGHT_SMALL); + yOffset += FONT_HEIGHT_SMALL - 3; + + if (y + yOffset > display->getHeight() - FONT_HEIGHT_SMALL) { + yOffset = FONT_HEIGHT_SMALL - 3; + col++; + if (col > 1) break; + } + } + + drawColumnSeparator(display, x, y + FONT_HEIGHT_SMALL - 2, lastNodeY); +} + +// Public screen entry for compass +static void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + drawNodeListWithExtrasScreen(display, state, x, y, "Bearings", drawEntryCompass, drawCompassArrow); +} + +void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth) { + int screenWidth = display->getWidth(); + bool isLeftCol = (x < screenWidth / 2); + int nameMaxWidth = columnWidth - (screenWidth > 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) { + snprintf(distStr, sizeof(distStr), "%dft", (int)(miles * 5280)); // show feet + } else if (miles < 10.0) { + snprintf(distStr, sizeof(distStr), "%.1fmi", miles); // 1 decimal + } else { + snprintf(distStr, sizeof(distStr), "%dmi", (int)miles); // no decimal + } + } else { + if (distanceKm < 1.0) { + snprintf(distStr, sizeof(distStr), "%dm", (int)(distanceKm * 1000)); // show meters + } else if (distanceKm < 10.0) { + snprintf(distStr, sizeof(distStr), "%.1fkm", distanceKm); // 1 decimal + } else { + snprintf(distStr, sizeof(distStr), "%dkm", (int)distanceKm); // no decimal + } + } + } + + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + display->drawStringMaxWidth(x, y, nameMaxWidth, nodeName); + + if (strlen(distStr) > 0) { + int offset = (screenWidth > 128) ? (isLeftCol ? 55 : 63) : (isLeftCol ? 32 : 37); + display->drawString(x + columnWidth - offset, y, distStr); + } +} + +static void drawDistanceScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + drawNodeListScreen(display, state, x, y, "Distances", drawNodeDistance); +} + + +static void drawDefaultScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + bool isInverted = (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); + bool isBold = config.display.heading_bold; + + const int xOffset = 3; // Padding for top row edges + + // Top row highlight (like drawScreenHeader) + if (isInverted) { + drawRoundedHighlight(display, 0, y, SCREEN_WIDTH, FONT_HEIGHT_SMALL - 2, 2); + display->setColor(BLACK); + } + + // Top row: Battery icon, %, Voltage + drawBattery(display, x + xOffset, y + 1, imgBattery, powerStatus); + + char percentStr[8]; + snprintf(percentStr, sizeof(percentStr), "%d%%", powerStatus->getBatteryChargePercent()); + int percentX = x + xOffset + 18; + display->drawString(percentX, y, percentStr); + + char voltStr[10]; + int batV = powerStatus->getBatteryVoltageMv() / 1000; + int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10; + snprintf(voltStr, sizeof(voltStr), "%d.%02dV", batV, batCv); + int voltX = SCREEN_WIDTH - xOffset - display->getStringWidth(voltStr); + display->drawString(voltX, y, voltStr); + + // Bold only for header row + if (isBold) { + display->drawString(percentX + 1, y, percentStr); + display->drawString(voltX + 1, y, voltStr); + } + + display->setColor(WHITE); + + // === Temporarily disable bold for second row === + bool origBold = config.display.heading_bold; + config.display.heading_bold = false; + + // Second row: Node count and satellite info + int secondRowY = y + FONT_HEIGHT_SMALL + 1; + drawNodes(display, x, secondRowY, nodeStatus); + +#if HAS_GPS + if (config.position.fixed_position) { + drawGPS(display, SCREEN_WIDTH - 44, secondRowY, gpsStatus); + } else if (!gpsStatus || !gpsStatus->getIsConnected()) { + // Show fallback: either "GPS off" or "No GPS" + String displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off"; + int posX = SCREEN_WIDTH - display->getStringWidth(displayLine) - 2; + display->drawString(posX, secondRowY, displayLine); + } else { + drawGPS(display, SCREEN_WIDTH - 44, secondRowY, gpsStatus); + } +#endif + + // Restore original bold setting + config.display.heading_bold = origBold; + + // Third row: Centered LongName + meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum()); + if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) { + const char* longName = ourNode->user.long_name; + int textWidth = display->getStringWidth(longName); + int nameX = (SCREEN_WIDTH - textWidth) / 2; + int nameY = y + (FONT_HEIGHT_SMALL + 1) * 2; + display->drawString(nameX, nameY, longName); + } + + // Fourth row: Centered uptime string (e.g. 1m, 2h, 3d) + uint32_t uptime = millis() / 1000; + char uptimeStr[6]; + uint32_t minutes = uptime / 60; + uint32_t hours = minutes / 60; + uint32_t 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'); + } + + int uptimeY = y + (FONT_HEIGHT_SMALL + 1) * 3; + int uptimeX = (SCREEN_WIDTH - display->getStringWidth(uptimeStr)) / 2; + display->drawString(uptimeX, uptimeY, uptimeStr); +} + #if defined(ESP_PLATFORM) && defined(USE_ST7789) SPIClass SPI1(HSPI); @@ -2147,6 +2644,12 @@ void Screen::setFrames(FrameFocus focus) normalFrames[numframes++] = drawTextMessageFrame; } + normalFrames[numframes++] = drawDefaultScreen; + normalFrames[numframes++] = drawLastHeardScreen; + normalFrames[numframes++] = drawDistanceScreen; + normalFrames[numframes++] = drawNodeListWithCompasses; + normalFrames[numframes++] = drawHopSignalScreen; + // then all the nodes // We only show a few nodes in our scrolling list - because meshes with many nodes would have too many screens size_t numToShow = min(numMeshNodes, 4U); @@ -2664,14 +3167,19 @@ void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *stat // 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 = ""; - // Show uptime as days, hours, minutes OR seconds - std::string uptime = screen->drawTimeDelta(days, hours, minutes, seconds); - uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone if (rtc_sec > 0) { long hms = rtc_sec % SEC_PER_DAY; @@ -2704,9 +3212,6 @@ void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *stat analogClock += timebuf; } - // Line 1 - display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str()); - // Line 2 display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 2a5ec00ab..1e83b3d1a 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -121,6 +121,73 @@ int CannedMessageModule::splitConfiguredMessages() return this->messagesCount; } +void CannedMessageModule::resetSearch() { + LOG_INFO("Resetting search, restoring full destination list"); + updateFilteredNodes(); // Reload all nodes and channels + requestFocus(); +} +void CannedMessageModule::updateFilteredNodes() { + static size_t lastNumMeshNodes = 0; // Track the last known node count + static String lastSearchQuery = ""; // Track last search query + + size_t numMeshNodes = nodeDB->getNumMeshNodes(); + + // If the number of nodes has changed, force an update + bool nodesChanged = (numMeshNodes != lastNumMeshNodes); + lastNumMeshNodes = numMeshNodes; + + // Also check if search query changed + if (searchQuery == lastSearchQuery && !nodesChanged) return; + + lastSearchQuery = searchQuery; + needsUpdate = false; + + this->filteredNodes.clear(); + this->activeChannelIndices.clear(); + + NodeNum myNodeNum = nodeDB->getNodeNum(); + + for (size_t i = 0; i < numMeshNodes; i++) { + meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + if (!node || node->num == myNodeNum) continue; + + String nodeName = node->user.long_name; + String lowerNodeName = nodeName; + String lowerSearchQuery = searchQuery; + + lowerNodeName.toLowerCase(); + lowerSearchQuery.toLowerCase(); + + if (searchQuery.length() == 0 || lowerNodeName.indexOf(lowerSearchQuery) != -1) { + this->filteredNodes.push_back({node, sinceLastSeen(node)}); + } + } + + // Populate active channels + this->activeChannelIndices.clear(); + std::vector seenChannels; + for (uint8_t i = 0; i < channels.getNumChannels(); i++) { + String channelName = channels.getName(i); + if (channelName.length() > 0 && std::find(seenChannels.begin(), seenChannels.end(), channelName) == seenChannels.end()) { + this->activeChannelIndices.push_back(i); + seenChannels.push_back(channelName); + } + } + + // Sort nodes by favorite status and last seen time + std::sort(this->filteredNodes.begin(), this->filteredNodes.end(), [](const NodeEntry &a, const NodeEntry &b) { + if (a.node->is_favorite != b.node->is_favorite) { + return a.node->is_favorite > b.node->is_favorite; // Favorited nodes first + } + return a.lastHeard < b.lastHeard; // Otherwise, sort by last heard (oldest first) + }); + + // πŸ”Ή If nodes have changed, refresh the screen + if (nodesChanged) { + LOG_INFO("Nodes changed, forcing UI refresh."); + screen->forceDisplay(); + } +} int CannedMessageModule::handleInputEvent(const InputEvent *event) { @@ -136,17 +203,157 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) { return 0; // Ignore input while sending } - bool validEvent = false; - if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP)) { - if (this->messagesCount > 0) { - this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; - validEvent = true; + static int lastDestIndex = -1; // Cache the last index + bool selectionChanged = false; // Track if UI needs redrawing + + bool isUp = event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP); + bool isDown = event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN); + + if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { + if (event->kbchar >= 32 && event->kbchar <= 126) { + this->searchQuery += event->kbchar; + return 0; } + + size_t numMeshNodes = this->filteredNodes.size(); + int totalEntries = numMeshNodes + this->activeChannelIndices.size(); + int columns = 2; + int totalRows = (totalEntries + columns - 1) / columns; + int maxScrollIndex = std::max(0, totalRows - this->visibleRows); + scrollIndex = std::max(0, std::min(scrollIndex, maxScrollIndex)); + + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK)) { + if (this->searchQuery.length() > 0) { + this->searchQuery.remove(this->searchQuery.length() - 1); + } + if (this->searchQuery.length() == 0) { + resetSearch(); // Function to restore all destinations + } + return 0; + } + + bool needsRedraw = false; + + // πŸ”Ό UP Navigation in Node Selection + if (isUp) { + if ((this->destIndex / columns) <= scrollIndex) { + if (scrollIndex > 0) { + scrollIndex--; + needsRedraw = true; + } + } else if (this->destIndex >= columns) { + this->destIndex -= columns; + } + } + + // πŸ”½ DOWN Navigation in Node Selection + if (isDown) { + if ((this->destIndex / columns) >= (scrollIndex + this->visibleRows - 1)) { + if (scrollIndex < maxScrollIndex) { + scrollIndex++; + needsRedraw = true; + } + } else if (this->destIndex + columns < totalEntries) { + this->destIndex += columns; + } + } + + + // β—€ LEFT Navigation (Wrap to previous row OR last row) + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) { + if (this->destIndex % columns == 0) { + if (this->destIndex >= columns) { + this->destIndex = this->destIndex - columns + (columns - 1); + } else { + int lastRowStart = ((totalEntries - 1) / columns) * columns; + this->destIndex = std::min(lastRowStart + (columns - 1), totalEntries - 1); + } + } else { + this->destIndex--; + } + } + + // β–Ά RIGHT Navigation (Wrap to next row OR first row) + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT)) { + int nextIndex = this->destIndex + 1; + if ((this->destIndex + 1) % columns == 0 || nextIndex >= totalEntries) { + if (this->destIndex + columns < totalEntries) { + this->destIndex = this->destIndex + columns - (columns - 1); + } else { + this->destIndex = 0; + } + } else { + this->destIndex++; + } + } + + if (this->destSelect != CANNED_MESSAGE_DESTINATION_TYPE_NODE) { + if (isUp && this->messagesCount > 0) { + this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; + return 0; + } + if (isDown && this->messagesCount > 0) { + this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN; + return 0; + } + } + // Only refresh UI when needed + if (needsRedraw) { + screen->forceDisplay(); + } + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) { + if (this->destIndex < static_cast(this->activeChannelIndices.size())) { + this->dest = NODENUM_BROADCAST; + this->channel = this->activeChannelIndices[this->destIndex]; + } else { + int nodeIndex = this->destIndex - static_cast(this->activeChannelIndices.size()); + if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { + meshtastic_NodeInfoLite *selectedNode = this->filteredNodes[nodeIndex].node; + if (selectedNode) { + this->dest = selectedNode->num; + this->channel = selectedNode->channel; + } + } + } + + // βœ… Now correctly switches to FreeText screen with selected node/channel + this->runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; + screen->forceDisplay(); + return 0; + } + + // Handle Cancel (ESC) + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL)) { + this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; // Ensure return to main screen + this->searchQuery = ""; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + + screen->forceDisplay(); + return 0; // πŸš€ Prevents input from affecting canned messages + } + + return 0; // πŸš€ FINAL EARLY EXIT: Stops the function from continuing into canned message handling } - if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN)) { - if (this->messagesCount > 0) { - this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN; - validEvent = true; + // If we reach here, we are NOT in Select Destination mode. + // The remaining logic is for canned message handling. + bool validEvent = false; + if (this->destSelect != CANNED_MESSAGE_DESTINATION_TYPE_NODE) { + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP)) { + if (this->messagesCount > 0) { + this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; + validEvent = true; + } + } + if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_DOWN)) { + if (this->messagesCount > 0) { + this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_DOWN; + validEvent = true; + } } } if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) { @@ -174,6 +381,15 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) } } if (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL)) { + // If in Node Selection Mode, exit and return to FreeText Mode + if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { + updateFilteredNodes(); // Ensure the filtered node list is refreshed before selecting + this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; + this->runState = CANNED_MESSAGE_RUN_STATE_FREETEXT; + return 0; + } + + // Default behavior for Cancel in other modes UIFrameEvent e; e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen this->currentMessageIndex = -1; @@ -186,6 +402,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; this->notifyObservers(&e); + screen->forceDisplay(); // Ensure the UI updates properly + return 0; } if ((event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK)) || (event->inputEvent == static_cast(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) || @@ -430,6 +648,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha int32_t CannedMessageModule::runOnce() { + updateFilteredNodes(); if (((!moduleConfig.canned_message.enabled) && !CANNED_MESSAGE_MODULE_ENABLE) || (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { temporaryMessage = ""; @@ -469,7 +688,7 @@ int32_t CannedMessageModule::runOnce() } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->freetext.length() > 0) { - sendText(this->dest, indexChannels[this->channel], this->freetext.c_str(), true); + sendText(this->dest, this->channel, this->freetext.c_str(), true); this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; } else { this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; @@ -483,7 +702,7 @@ int32_t CannedMessageModule::runOnce() #if defined(USE_VIRTUAL_KEYBOARD) sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true); #else - sendText(NODENUM_BROADCAST, channels.getPrimaryIndex(), this->messages[this->currentMessageIndex], true); + sendText(this->dest, this->channel, this->messages[this->currentMessageIndex], true); #endif } this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; @@ -624,13 +843,23 @@ int32_t CannedMessageModule::runOnce() this->cursor--; } break; - case 0x09: // tab - if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL) { - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; - } else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL; - } else { - this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; + case 0x09: // Tab key (Switch to Destination Selection Mode) + { + if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NONE) { + // Enter selection screen + this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; + this->destIndex = 0; // Reset to first node/channel + this->scrollIndex = 0; // Reset scrolling + this->runState = CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION; + + // Ensure UI updates correctly + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + } + + // If already inside the selection screen, do nothing (prevent exiting) + return 0; } break; case INPUT_BROKER_MSG_LEFT: @@ -986,7 +1215,10 @@ bool CannedMessageModule::interceptingKeyboardInput() #if !HAS_TFT void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { + this->displayHeight = display->getHeight(); // Store display height for later use char buffer[50]; + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); if (temporaryMessage.length() != 0) { requestFocus(); // Tell Screen::setFrames to move to our module's frame @@ -997,6 +1229,7 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) { requestFocus(); // Tell Screen::setFrames to move to our module's frame EINK_ADD_FRAMEFLAG(display, COSMETIC); // Clean after this popup. Layout makes ghosting particularly obvious + display->setTextAlignment(TEXT_ALIGN_CENTER); #ifdef USE_EINK display->setFont(FONT_SMALL); // No chunky text @@ -1055,11 +1288,83 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled."); + } else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { + requestFocus(); + updateFilteredNodes(); + display->clear(); + display->setTextAlignment(TEXT_ALIGN_LEFT); + display->setFont(FONT_SMALL); + + int titleY = 2; + String titleText = "Select Destination"; + titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]"; + display->drawString(display->getWidth() / 2 - display->getStringWidth(titleText) / 2, titleY, titleText); + + int rowYOffset = titleY + FONT_HEIGHT_SMALL; // Adjusted for search box spacing + int numActiveChannels = this->activeChannelIndices.size(); + int totalEntries = numActiveChannels + this->filteredNodes.size(); + int columns = 2; + this->visibleRows = (display->getHeight() - (titleY + FONT_HEIGHT_SMALL)) / FONT_HEIGHT_SMALL; + if (this->visibleRows < 1) this->visibleRows = 1; + + // Ensure scrolling within bounds + if (scrollIndex > totalEntries / columns) scrollIndex = totalEntries / columns; + if (scrollIndex < 0) scrollIndex = 0; + + for (int row = 0; row < visibleRows; row++) { + int itemIndex = (scrollIndex + row) * columns; + for (int col = 0; col < columns; col++) { + if (itemIndex >= totalEntries) break; + + int xOffset = col * (display->getWidth() / columns); + int yOffset = row * FONT_HEIGHT_SMALL + rowYOffset; + String entryText; + + // Draw Channels First + if (itemIndex < numActiveChannels) { + uint8_t channelIndex = this->activeChannelIndices[itemIndex]; + entryText = String("@") + String(channels.getName(channelIndex)); + } + // Then Draw Nodes + else { + int nodeIndex = itemIndex - numActiveChannels; + if (nodeIndex >= 0 && nodeIndex < static_cast(this->filteredNodes.size())) { + meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node; + entryText = node ? (node->is_favorite ? "* " + String(node->user.long_name) : String(node->user.long_name)) : "?"; + } + } + + // Prevent Empty Names + if (entryText.length() == 0 || entryText == "Unknown") entryText = "?"; + + // Trim if Too Long + while (display->getStringWidth(entryText + "-") > (display->getWidth() / columns - 4)) { + entryText = entryText.substring(0, entryText.length() - 1); + } + + // Highlight Selection + if (itemIndex == destIndex) { + display->fillRect(xOffset, yOffset, display->getStringWidth(entryText) + 4, FONT_HEIGHT_SMALL + 2); + display->setColor(BLACK); + } + display->drawString(xOffset + 2, yOffset, entryText); + display->setColor(WHITE); + itemIndex++; + } + } + if (totalEntries > visibleRows * columns) { + display->drawRect(display->getWidth() - 6, rowYOffset, 4, visibleRows * FONT_HEIGHT_SMALL); + int totalPages = (totalEntries + columns - 1) / columns; + int scrollHeight = (visibleRows * FONT_HEIGHT_SMALL * visibleRows) / (totalPages); + int scrollPos = rowYOffset + ((visibleRows * FONT_HEIGHT_SMALL) * scrollIndex) / totalPages; + display->fillRect(display->getWidth() - 6, scrollPos, 4, scrollHeight); + } + screen->forceDisplay(); } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) { requestFocus(); // Tell Screen::setFrames to move to our module's frame #if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) - EInkDynamicDisplay *einkDisplay = static_cast(display); - einkDisplay->enableUnlimitedFastMode(); // Enable unlimited fast refresh while typing + EInkDynamicDisplay* einkDisplay = static_cast(display); + einkDisplay->enableUnlimitedFastMode(); // Enable unlimited fast refresh while typing #endif #if defined(USE_VIRTUAL_KEYBOARD) @@ -1075,23 +1380,24 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st switch (this->destSelect) { case CANNED_MESSAGE_DESTINATION_TYPE_NODE: display->drawStringf(1 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); + LOG_INFO("Displaying recipient: Node=%s (ID=%d)", cannedMessageModule->getNodeName(this->dest), this->dest); display->drawStringf(0 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); break; case CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL: display->drawStringf(1 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); display->drawStringf(0 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); break; default: if (display->getWidth() > 128) { display->drawStringf(0 + x, 0 + y, buffer, "To: %s@%s", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); } else { display->drawStringf(0 + x, 0 + y, buffer, "To: %.5s@%.5s", cannedMessageModule->getNodeName(this->dest), - channels.getName(indexChannels[this->channel])); + channels.getName(this->channel)); } break; } diff --git a/src/modules/CannedMessageModule.h b/src/modules/CannedMessageModule.h index a91933a0f..f044d4e85 100644 --- a/src/modules/CannedMessageModule.h +++ b/src/modules/CannedMessageModule.h @@ -14,6 +14,7 @@ enum cannedMessageModuleRunState { CANNED_MESSAGE_RUN_STATE_ACTION_SELECT, CANNED_MESSAGE_RUN_STATE_ACTION_UP, CANNED_MESSAGE_RUN_STATE_ACTION_DOWN, + CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION }; enum cannedMessageDestinationType { @@ -43,10 +44,23 @@ struct Letter { #define CANNED_MESSAGE_MODULE_ENABLE 0 #endif +struct NodeEntry { + meshtastic_NodeInfoLite *node; + uint32_t lastHeard; +}; + class CannedMessageModule : public SinglePortModule, public Observable, private concurrency::OSThread { CallbackObserver inputObserver = CallbackObserver(this, &CannedMessageModule::handleInputEvent); + private: + int displayHeight = 64; // Default to a common value, update dynamically + int destIndex = 0; // Tracks currently selected node/channel in selection mode + int scrollIndex = 0; // Tracks scrolling position in node selection grid + int visibleRows = 0; + bool needsUpdate = true; + String searchQuery; + std::vector activeChannelIndices; public: CannedMessageModule(); @@ -65,7 +79,10 @@ class CannedMessageModule : public SinglePortModule, public Observable filteredNodes; + String nodeSelectionInput; String drawWithCursor(String text, int cursor); #ifdef RAK14014