New Screens

Introducing
Default screen
Nodelist (Last head nodes)
Distance Screen
Compass screen
Hops and Signal Screen

Improved node receipient list using navigation bar and search bar for canned messages
This commit is contained in:
HarukiToreda 2025-03-28 14:39:15 -04:00
parent d28af68b5a
commit 1dedd291fb
3 changed files with 872 additions and 44 deletions

View File

@ -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"); display->drawString(x + 1, y - 2, "No GPS");
return; return;
} }
// Adjust position if were 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); display->drawFastImage(x, y, 6, 8, gps->getHasLock() ? imgPositionSolid : imgPositionEmpty);
if (!gps->getHasLock()) { 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) if (config.display.heading_bold)
display->drawString(x + 9, y - 2, "No sats"); display->drawString(textX + 1, y - 2, "No sats");
return; return;
} else { } else {
char satsString[3]; 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); bar[0] = ~((1 << (5 - i)) - 1);
else else
bar[0] = 0b10000000; bar[0] = 0b10000000;
// bar[1] = bar[0];
display->drawFastImage(x + 9 + (i * 2), y, 2, 8, bar); 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 // Draw the number of satellites
snprintf(satsString, sizeof(satsString), "%u", gps->getNumSatellites()); 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) 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 // Draw status when GPS is disabled or not present
static void drawGPSpowerstat(OLEDDisplay *display, int16_t x, int16_t y, const GPSStatus *gps) 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 // 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) 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 Point tip(0.0f, 0.5f), tail(0.0f, -0.35f); // pointing up initially
float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f; float arrowOffsetX = 0.14f, arrowOffsetY = 1.0f;
Point leftArrow(tip.x - arrowOffsetX, tip.y - arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y - arrowOffsetY); 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(leftArrow.x, leftArrow.y, tail.x, tail.y);
display->drawLine(rightArrow.x, rightArrow.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 #ifdef USE_EINK
display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y); display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#else #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) 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 north is supposed to be at the top of the compass we want rotation to be +0
if (config.display.compass_north_top) if (config.display.compass_north_top)
myHeading = -0; myHeading = -0;
@ -1369,13 +1399,10 @@ void Screen::drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t co
rosePoints[i]->scale(compassDiam); rosePoints[i]->scale(compassDiam);
rosePoints[i]->translate(compassX, compassY); 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. 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) 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 // Must be after distStr is populated
screen->drawColumns(display, x, y, fields); 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<NodeEntry> &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<NodeEntry> 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<NodeEntry> 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) #if defined(ESP_PLATFORM) && defined(USE_ST7789)
SPIClass SPI1(HSPI); SPIClass SPI1(HSPI);
@ -2147,6 +2644,12 @@ void Screen::setFrames(FrameFocus focus)
normalFrames[numframes++] = drawTextMessageFrame; normalFrames[numframes++] = drawTextMessageFrame;
} }
normalFrames[numframes++] = drawDefaultScreen;
normalFrames[numframes++] = drawLastHeardScreen;
normalFrames[numframes++] = drawDistanceScreen;
normalFrames[numframes++] = drawNodeListWithCompasses;
normalFrames[numframes++] = drawHopSignalScreen;
// then all the nodes // then all the nodes
// We only show a few nodes in our scrolling list - because meshes with many nodes would have too many screens // 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); size_t numToShow = min(numMeshNodes, 4U);
@ -2664,14 +3167,19 @@ void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *stat
// minutes %= 60; // minutes %= 60;
// hours %= 24; // 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); display->setColor(WHITE);
// Setup string to assemble analogClock string // Setup string to assemble analogClock string
std::string analogClock = ""; 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 uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
if (rtc_sec > 0) { if (rtc_sec > 0) {
long hms = rtc_sec % SEC_PER_DAY; long hms = rtc_sec % SEC_PER_DAY;
@ -2704,9 +3212,6 @@ void DebugInfo::drawFrameSettings(OLEDDisplay *display, OLEDDisplayUiState *stat
analogClock += timebuf; analogClock += timebuf;
} }
// Line 1
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(uptime.c_str()), y, uptime.c_str());
// Line 2 // Line 2
display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str()); display->drawString(x, y + FONT_HEIGHT_SMALL * 1, analogClock.c_str());

View File

@ -121,6 +121,73 @@ int CannedMessageModule::splitConfiguredMessages()
return this->messagesCount; 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<String> 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) int CannedMessageModule::handleInputEvent(const InputEvent *event)
{ {
@ -136,7 +203,146 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) { if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) {
return 0; // Ignore input while sending return 0; // Ignore input while sending
} }
static int lastDestIndex = -1; // Cache the last index
bool selectionChanged = false; // Track if UI needs redrawing
bool isUp = event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP);
bool isDown = event->inputEvent == static_cast<char>(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<char>(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<char>(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<char>(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<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) {
if (this->destIndex < static_cast<int>(this->activeChannelIndices.size())) {
this->dest = NODENUM_BROADCAST;
this->channel = this->activeChannelIndices[this->destIndex];
} else {
int nodeIndex = this->destIndex - static_cast<int>(this->activeChannelIndices.size());
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(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<char>(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 we reach here, we are NOT in Select Destination mode.
// The remaining logic is for canned message handling.
bool validEvent = false; bool validEvent = false;
if (this->destSelect != CANNED_MESSAGE_DESTINATION_TYPE_NODE) {
if (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP)) { if (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_UP)) {
if (this->messagesCount > 0) { if (this->messagesCount > 0) {
this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP; this->runState = CANNED_MESSAGE_RUN_STATE_ACTION_UP;
@ -149,6 +355,7 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
validEvent = true; validEvent = true;
} }
} }
}
if (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) { if (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT)) {
#if defined(USE_VIRTUAL_KEYBOARD) #if defined(USE_VIRTUAL_KEYBOARD)
@ -174,6 +381,15 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
} }
} }
if (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_CANCEL)) { if (event->inputEvent == static_cast<char>(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; UIFrameEvent e;
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; // We want to change the list of frames shown on-screen
this->currentMessageIndex = -1; this->currentMessageIndex = -1;
@ -186,6 +402,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
this->notifyObservers(&e); this->notifyObservers(&e);
screen->forceDisplay(); // Ensure the UI updates properly
return 0;
} }
if ((event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK)) || if ((event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_BACK)) ||
(event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) || (event->inputEvent == static_cast<char>(meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT)) ||
@ -430,6 +648,7 @@ void CannedMessageModule::sendText(NodeNum dest, ChannelIndex channel, const cha
int32_t CannedMessageModule::runOnce() int32_t CannedMessageModule::runOnce()
{ {
updateFilteredNodes();
if (((!moduleConfig.canned_message.enabled) && !CANNED_MESSAGE_MODULE_ENABLE) || if (((!moduleConfig.canned_message.enabled) && !CANNED_MESSAGE_MODULE_ENABLE) ||
(this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { (this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) {
temporaryMessage = ""; temporaryMessage = "";
@ -469,7 +688,7 @@ int32_t CannedMessageModule::runOnce()
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) {
if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
if (this->freetext.length() > 0) { 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; this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
} else { } else {
this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE;
@ -483,7 +702,7 @@ int32_t CannedMessageModule::runOnce()
#if defined(USE_VIRTUAL_KEYBOARD) #if defined(USE_VIRTUAL_KEYBOARD)
sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true); sendText(this->dest, indexChannels[this->channel], this->messages[this->currentMessageIndex], true);
#else #else
sendText(NODENUM_BROADCAST, channels.getPrimaryIndex(), this->messages[this->currentMessageIndex], true); sendText(this->dest, this->channel, this->messages[this->currentMessageIndex], true);
#endif #endif
} }
this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE;
@ -624,13 +843,23 @@ int32_t CannedMessageModule::runOnce()
this->cursor--; this->cursor--;
} }
break; break;
case 0x09: // tab case 0x09: // Tab key (Switch to Destination Selection Mode)
if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL) { {
this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NONE; if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NONE) {
} else if (this->destSelect == CANNED_MESSAGE_DESTINATION_TYPE_NODE) { // Enter selection screen
this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL;
} else {
this->destSelect = CANNED_MESSAGE_DESTINATION_TYPE_NODE; 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; break;
case INPUT_BROKER_MSG_LEFT: case INPUT_BROKER_MSG_LEFT:
@ -986,7 +1215,10 @@ bool CannedMessageModule::interceptingKeyboardInput()
#if !HAS_TFT #if !HAS_TFT
void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) 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]; char buffer[50];
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
if (temporaryMessage.length() != 0) { if (temporaryMessage.length() != 0) {
requestFocus(); // Tell Screen::setFrames to move to our module's frame 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) { } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) {
requestFocus(); // Tell Screen::setFrames to move to our module's frame 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 EINK_ADD_FRAMEFLAG(display, COSMETIC); // Clean after this popup. Layout makes ghosting particularly obvious
display->setTextAlignment(TEXT_ALIGN_CENTER);
#ifdef USE_EINK #ifdef USE_EINK
display->setFont(FONT_SMALL); // No chunky text display->setFont(FONT_SMALL); // No chunky text
@ -1055,10 +1288,82 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
display->setTextAlignment(TEXT_ALIGN_LEFT); display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL); display->setFont(FONT_SMALL);
display->drawString(10 + x, 0 + y + FONT_HEIGHT_SMALL, "Canned Message\nModule disabled."); 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<int>(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) { } else if (cannedMessageModule->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
requestFocus(); // Tell Screen::setFrames to move to our module's frame requestFocus(); // Tell Screen::setFrames to move to our module's frame
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY) #if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
EInkDynamicDisplay *einkDisplay = static_cast<EInkDynamicDisplay *>(display); EInkDynamicDisplay* einkDisplay = static_cast<EInkDynamicDisplay*>(display);
einkDisplay->enableUnlimitedFastMode(); // Enable unlimited fast refresh while typing einkDisplay->enableUnlimitedFastMode(); // Enable unlimited fast refresh while typing
#endif #endif
@ -1075,23 +1380,24 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
switch (this->destSelect) { switch (this->destSelect) {
case CANNED_MESSAGE_DESTINATION_TYPE_NODE: case CANNED_MESSAGE_DESTINATION_TYPE_NODE:
display->drawStringf(1 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest), 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), display->drawStringf(0 + x, 0 + y, buffer, "To: >%s<@%s", cannedMessageModule->getNodeName(this->dest),
channels.getName(indexChannels[this->channel])); channels.getName(this->channel));
break; break;
case CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL: case CANNED_MESSAGE_DESTINATION_TYPE_CHANNEL:
display->drawStringf(1 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest), 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), display->drawStringf(0 + x, 0 + y, buffer, "To: %s@>%s<", cannedMessageModule->getNodeName(this->dest),
channels.getName(indexChannels[this->channel])); channels.getName(this->channel));
break; break;
default: default:
if (display->getWidth() > 128) { if (display->getWidth() > 128) {
display->drawStringf(0 + x, 0 + y, buffer, "To: %s@%s", cannedMessageModule->getNodeName(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));
} else { } else {
display->drawStringf(0 + x, 0 + y, buffer, "To: %.5s@%.5s", cannedMessageModule->getNodeName(this->dest), display->drawStringf(0 + x, 0 + y, buffer, "To: %.5s@%.5s", cannedMessageModule->getNodeName(this->dest),
channels.getName(indexChannels[this->channel])); channels.getName(this->channel));
} }
break; break;
} }

View File

@ -14,6 +14,7 @@ enum cannedMessageModuleRunState {
CANNED_MESSAGE_RUN_STATE_ACTION_SELECT, CANNED_MESSAGE_RUN_STATE_ACTION_SELECT,
CANNED_MESSAGE_RUN_STATE_ACTION_UP, CANNED_MESSAGE_RUN_STATE_ACTION_UP,
CANNED_MESSAGE_RUN_STATE_ACTION_DOWN, CANNED_MESSAGE_RUN_STATE_ACTION_DOWN,
CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION
}; };
enum cannedMessageDestinationType { enum cannedMessageDestinationType {
@ -43,10 +44,23 @@ struct Letter {
#define CANNED_MESSAGE_MODULE_ENABLE 0 #define CANNED_MESSAGE_MODULE_ENABLE 0
#endif #endif
struct NodeEntry {
meshtastic_NodeInfoLite *node;
uint32_t lastHeard;
};
class CannedMessageModule : public SinglePortModule, public Observable<const UIFrameEvent *>, private concurrency::OSThread class CannedMessageModule : public SinglePortModule, public Observable<const UIFrameEvent *>, private concurrency::OSThread
{ {
CallbackObserver<CannedMessageModule, const InputEvent *> inputObserver = CallbackObserver<CannedMessageModule, const InputEvent *> inputObserver =
CallbackObserver<CannedMessageModule, const InputEvent *>(this, &CannedMessageModule::handleInputEvent); CallbackObserver<CannedMessageModule, const InputEvent *>(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<uint8_t> activeChannelIndices;
public: public:
CannedMessageModule(); CannedMessageModule();
@ -65,7 +79,10 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
void handleSetCannedMessageModuleMessages(const char *from_msg); void handleSetCannedMessageModuleMessages(const char *from_msg);
void showTemporaryMessage(const String &message); void showTemporaryMessage(const String &message);
void resetSearch();
void updateFilteredNodes();
std::vector<NodeEntry> filteredNodes;
String nodeSelectionInput;
String drawWithCursor(String text, int cursor); String drawWithCursor(String text, int cursor);
#ifdef RAK14014 #ifdef RAK14014