Full T-echo card support + Compact UI (#11342)

* T-echo card

* Update NRF52I2SOutput.cpp

* Update NRF52I2SOutput.h

* cleanup

* Update buzz.cpp

* use consistent runtime compact-panel check instead of mixing with compile-time macro

* Update NodeDB.cpp

* Update ExternalNotificationModule.cpp

* switched to Throttle::isWithinTimespanMs

* Update SharedUIDisplay.h

* trunk fix

* last cleanup

* ClockRenderer.cpp for OLED_COMPACT_UI and setup Unit C6L for new UI.

* Fixed regressions in standard OLED and TFT

---------

Co-authored-by: Jason P <applewiz@mac.com>
This commit is contained in:
HarukiToreda
2026-08-04 14:27:35 +00:00
committed by GitHub
co-authored by Jason P
parent d89f3eb8fc
commit 39e7aa6a6c
26 changed files with 1231 additions and 186 deletions
+393 -39
View File
@@ -116,7 +116,8 @@ static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compa
graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius);
}
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
bool showRing = true)
{
const StandardCompassNeedlePoints points =
computeStandardCompassNeedlePoints(compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, 0.0f);
@@ -141,7 +142,8 @@ static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16
points.southRightY);
#endif
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading);
}
@@ -350,7 +352,8 @@ static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compa
#endif
}
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading,
bool showRing = true)
{
// Compass colors should follow whatever background role is already active at this location.
const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg());
@@ -375,7 +378,8 @@ static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_
}
drawStandardCompassNeedle(display, compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, compassBgColor);
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading);
drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading);
}
@@ -392,10 +396,11 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_
static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
bool showCompass, float myHeading, float bearing, const char *statusLine1,
const char *statusLine2)
const char *statusLine2, bool showRing = true)
{
// Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
if (showCompass) {
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
@@ -405,17 +410,19 @@ static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, i
}
static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
bool validHeading, float heading, const char *statusLine1, const char *statusLine2)
bool validHeading, float heading, const char *statusLine1, const char *statusLine2,
bool showRing = true)
{
// Shared "position screen" compass renderer: use mono/TFT path only when heading is valid.
if (validHeading) {
#if GRAPHICS_TFT_COLORING_ENABLED
drawTftCompass(display, compassX, compassY, compassRadius, heading);
drawTftCompass(display, compassX, compassY, compassRadius, heading, showRing);
#else
drawMonoCompass(display, compassX, compassY, compassRadius, heading);
drawMonoCompass(display, compassX, compassY, compassRadius, heading, showRing);
#endif
} else {
display->drawCircle(compassX, compassY, compassRadius);
if (showRing)
display->drawCircle(compassX, compassY, compassRadius);
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
}
}
@@ -508,15 +515,8 @@ extern uint32_t dopThresholds[5];
// Draw GPS status summary (satellite icon + status text).
// Handles all GPS states: disabled / not present / fixed position / no lock / sat count.
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps, bool center)
{
// Draw satellite image
if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y + 1, imgGPS_width, imgGPS_height, imgGPS, display);
} else {
display->drawXbm(x + 1, y + 3, imgGPS_width, imgGPS_height, imgGPS);
}
char textString[12];
if (config.position.fixed_position) {
// Fixed position overrides live GPS state, regardless of gps_mode
@@ -533,7 +533,20 @@ void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const mesht
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
}
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 18 : 11), y, textString);
const int textOffset = (currentResolution == ScreenResolution::High) ? 18 : 11;
if (center) {
int contentWidth = textOffset + display->getStringWidth(textString);
x = (SCREEN_WIDTH - contentWidth) / 2;
}
// Draw satellite image
if (currentResolution == ScreenResolution::High) {
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgGPS_width, imgGPS_height, imgGPS, display);
} else {
display->drawXbm(x + 1, y + 1, imgGPS_width, imgGPS_height, imgGPS);
}
display->drawString(x + textOffset, y, textString);
}
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
@@ -681,7 +694,7 @@ void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y,
// Draw nodes status
void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::NodeStatus *nodeStatus, int node_offset,
bool show_total, const char *additional_words)
bool show_total, const char *additional_words, bool center)
{
char usersString[20];
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
@@ -693,6 +706,12 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
}
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
if (center) {
int contentWidth = 10 + string_offset + display->getStringWidth(usersString);
x = (SCREEN_WIDTH - contentWidth) / 2;
}
#if (defined(USE_EINK) || defined(HAS_SPI_TFT)) && !defined(DISPLAY_FORCE_SMALL_FONTS)
if (currentResolution == ScreenResolution::High) {
@@ -707,13 +726,26 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
display->drawFastImage(x, y + 1, 8, 8, imgUser);
}
#endif
int string_offset = (currentResolution == ScreenResolution::High) ? 9 : 0;
display->drawString(x + 10 + string_offset, y - 2, usersString);
}
// **********************
// * Favorite Node Info *
// **********************
// Compact panels: toggle between the compass/distance view and the status/telemetry view.
static int favoriteViewIndex = 0;
void UIRenderer::scrollFavoriteDown()
{
favoriteViewIndex = (favoriteViewIndex + 1) % 2;
}
void UIRenderer::scrollFavoriteUp()
{
if (favoriteViewIndex > 0)
favoriteViewIndex--;
}
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
@@ -746,6 +778,158 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
// === Draw battery/time/mail header (common across screens) ===
graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow);
#if HAS_GPS && defined(OLED_COMPACT_UI)
// Compact panels: page 0 = name/distance/compass, page 1 = status/telemetry (scroll down)
if (graphics::isCompactPanel(display)) {
int cline = 1;
const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
meshtastic_PositionLite nodePos, ourPos;
const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos);
const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
const bool hasFix = hasOwnPositionFix && hasNodePositionFix && haveOurPos && haveNodePos;
if (favoriteViewIndex == 0) {
// --- Long name (falls back to short) ---
const char *rawName = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : shortName;
char nodeName[40];
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
UIRenderer::drawStringWithEmotes(display, 2, getTextPositions(display)[cline++], nodeName, FONT_HEIGHT_SMALL, 1,
false);
// --- Compass (bearing to node), right-aligned ---
bool showCompass = false;
float myHeading = 0.0f, bearing = 0.0f;
const char *statusLine1 = nullptr;
const char *statusLine2 = nullptr;
if (hasFix) {
showCompass = CompassRenderer::getHeadingRadians(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), myHeading);
if (showCompass) {
bearing = GeoCoord::bearing(DegD(ourPos.latitude_i), DegD(ourPos.longitude_i), DegD(nodePos.latitude_i),
DegD(nodePos.longitude_i));
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
} else {
statusLine1 = "No";
statusLine2 = "Heading";
}
} else {
statusLine1 = "No";
statusLine2 = "Fix";
}
const int compassTop = getTextPositions(display)[cline];
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
const int maxCompassDiameter = (SCREEN_WIDTH / 2 - 4 < availableHeight) ? (SCREEN_WIDTH / 2 - 4) : availableHeight;
int compassRadius = maxCompassDiameter / 2;
if (compassRadius < 8)
compassRadius = 8;
const int compassX = SCREEN_WIDTH - compassRadius - 4;
const int compassY = compassTop + availableHeight / 2;
drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1,
statusLine2, /*showRing=*/false);
// --- Distance, directly under the name, left side only, shown when a fix is available ---
if (hasFix) {
char distStr[16];
const float distanceMeters = GeoCoord::latLongToMeter(DegD(nodePos.latitude_i), DegD(nodePos.longitude_i),
DegD(ourPos.latitude_i), DegD(ourPos.longitude_i));
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
const int feet = static_cast<int>((distanceMeters * METERS_TO_FEET) + 0.5f);
if (feet < 1000)
snprintf(distStr, sizeof(distStr), "%dft", feet);
else
snprintf(distStr, sizeof(distStr), "%dmi", (feet + 2640) / 5280);
} else {
const int meters = static_cast<int>(distanceMeters + 0.5f);
if (meters < 1000)
snprintf(distStr, sizeof(distStr), "%dm", meters);
else
snprintf(distStr, sizeof(distStr), "%dkm", (meters + 500) / 1000);
}
display->drawString(2, getTextPositions(display)[cline++], distStr);
}
// --- Last heard, directly under distance ---
uint32_t seenSeconds = sinceLastSeen(node);
if (seenSeconds != 0 && seenSeconds != UINT32_MAX) {
uint32_t minutes = seenSeconds / 60, hours = minutes / 60, days = hours / 24;
char seenStr[16];
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "?" : "%d%c"),
(days ? days
: hours ? hours
: minutes),
(days ? 'd'
: hours ? 'h'
: 'm'));
display->drawString(2, getTextPositions(display)[cline++], seenStr);
}
} else {
// --- Page 1: status, signal/hops, heard, uptime, battery ---
meshtastic_StatusMessage cachedStatus;
if (nodeDB && nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
drawTruncatedStatusLine(display, x, getTextPositions(display)[cline++], cachedStatus.status);
}
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
const bool showHops = node->has_hops_away && node->hops_away > 0;
if (isZeroHop && node->snr > -100 && node->snr != 0) {
char sigStr[16];
snprintf(sigStr, sizeof(sigStr), "SNR:%.1f", node->snr);
display->drawString(x, getTextPositions(display)[cline++], sigStr);
} else if (showHops) {
char hopStr[16];
snprintf(hopStr, sizeof(hopStr), "Hops:%d", node->hops_away);
display->drawString(x, getTextPositions(display)[cline++], hopStr);
}
uint32_t seconds = sinceLastSeen(node);
if (seconds != 0 && seconds != UINT32_MAX) {
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
char seenStr[20];
snprintf(seenStr, sizeof(seenStr), (days > 365 ? "Heard:?" : "Heard:%d%c ago"),
(days ? days
: hours ? hours
: minutes),
(days ? 'd'
: hours ? 'h'
: 'm'));
display->drawString(x, getTextPositions(display)[cline++], seenStr);
}
meshtastic_DeviceMetrics nodeMetrics;
if (nodeDB->copyNodeTelemetry(node->num, nodeMetrics)) {
if (nodeMetrics.has_uptime_seconds) {
char uptimeStr[24];
getUptimeStr(nodeMetrics.uptime_seconds * 1000, "Up:", uptimeStr, sizeof(uptimeStr));
display->drawString(x, getTextPositions(display)[cline++], uptimeStr);
}
if (nodeMetrics.has_battery_level) {
char batStr[24];
int pct = (int)nodeMetrics.battery_level;
if (pct > 100) {
snprintf(batStr, sizeof(batStr), "Plugged In");
} else {
snprintf(batStr, sizeof(batStr), "Bat:%d%%", pct);
}
display->drawString(x, getTextPositions(display)[cline++], batStr);
}
}
}
// Two-page indicator, matching the position screen's scrollbar thumb style.
const int scrollbarX = SCREEN_WIDTH - 2;
const int thumbHeight = SCREEN_HEIGHT / 2;
const int thumbY = favoriteViewIndex * (SCREEN_HEIGHT - thumbHeight);
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
graphics::drawCommonFooter(display, x, y);
return;
}
#endif
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
// 2. Each row is only shown if it has valid data.
@@ -1140,9 +1324,19 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
const bool compactPanel = graphics::isCompactPanel(display);
if (!config.lora.tx_enabled) {
const char *txdisabled = "Transmit Disabled";
display->drawString(x, getTextPositions(display)[line], txdisabled);
if (compactPanel) {
int textWidth = display->getStringWidth(txdisabled);
display->drawString((SCREEN_WIDTH - textWidth) / 2, getTextPositions(display)[line], txdisabled);
} else {
display->drawString(x, getTextPositions(display)[line], txdisabled);
}
} else if (compactPanel) {
// No room for a separate left/right column layout - center it instead.
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online", true);
} else {
// Display Region and Channel Utilization
if (currentResolution == ScreenResolution::UltraLow) {
@@ -1155,27 +1349,48 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
if (currentResolution != ScreenResolution::UltraLow) {
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
}
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
if (!compactPanel) {
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
} else {
line++;
}
// === Second Row: Satellites and Voltage ===
config.display.heading_bold = false;
#if HAS_GPS
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus);
UIRenderer::drawGps(display, x, getTextPositions(display)[line], gpsStatus, compactPanel);
#endif
#if defined(OLED_TINY)
line += 1;
// === Node Identity ===
int textWidth = 0;
int nameX = 0;
const char *shortName = owner.short_name[0] ? owner.short_name : "";
if (compactPanel) {
// === Channel Utilization (compact, above name) ===
int chutil_percent = static_cast<int>(airTime->channelUtilizationPercent() + 0.5f);
char chUtilStr[16];
snprintf(chUtilStr, sizeof(chUtilStr), "ChUtil %d%%", chutil_percent);
int chUtilWidth = display->getStringWidth(chUtilStr);
display->drawString((SCREEN_WIDTH - chUtilWidth) / 2, getTextPositions(display)[line++], chUtilStr);
// === ShortName Centered ===
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
// === Node Identity: long name (falls back to short), truncated with "..." if too wide ===
const char *longName = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
const char *shortName = owner.short_name[0] ? owner.short_name : "";
const char *rawName = longName[0] ? longName : shortName;
char nodeName[32];
UIRenderer::truncateStringWithEmotes(display, rawName, nodeName, sizeof(nodeName), SCREEN_WIDTH - 4);
int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
int nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], nodeName, FONT_HEIGHT_SMALL, 1,
false);
} else {
// === Node Identity ===
const char *shortName = owner.short_name[0] ? owner.short_name : "";
int textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
int nameX = (SCREEN_WIDTH - textWidth) / 2;
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
false);
}
#else
if (powerStatus->getHasBattery()) {
char batStr[20];
@@ -1532,6 +1747,20 @@ void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display,
// ****************************
// * My Position Screen *
// ****************************
// Compact panels: 0 = compass, 1 = coordinates + elevation
static int positionViewIndex = 0;
void UIRenderer::scrollPositionDown()
{
positionViewIndex = (positionViewIndex + 1) % 2;
}
void UIRenderer::scrollPositionUp()
{
if (positionViewIndex > 0)
positionViewIndex--;
}
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
display->clear();
@@ -1545,13 +1774,14 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
// === Header ===
graphics::drawCommonHeader(display, x, y, titleStr);
const int *textPos = getTextPositions(display);
const bool compactPanel = graphics::isCompactPanel(display);
// === First Row: My Location ===
#if HAS_GPS
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus);
UIRenderer::drawGps(display, x, textPos[line++], gpsStatus, compactPanel);
config.display.heading_bold = origBold;
@@ -1594,8 +1824,61 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
}
}
// GPS data (coordinates/altitude) is available whenever GPS is enabled or a fixed position is set.
const bool hasGpsData =
config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position;
// Compact panels: GPS status row above is shared; scroll down for coordinates+elevation.
#if defined(OLED_COMPACT_UI)
if (compactPanel) {
if (positionViewIndex == 0) {
// Compass comes from the IMU/magnetometer, not GPS - keep it showing even with GPS off.
const int compassTop = textPos[line];
int availableHeight = SCREEN_HEIGHT - compassTop - 1;
int compassRadius = availableHeight / 2;
if (compassRadius < 8)
compassRadius = 8;
if (compassRadius * 2 > SCREEN_WIDTH - 4)
compassRadius = (SCREEN_WIDTH - 4) / 2;
drawDetailedCompassOrStatus(display, x + SCREEN_WIDTH / 2, compassTop + availableHeight / 2, compassRadius,
validHeading, heading, statusLine1, statusLine2, /*showRing=*/false);
} else if (hasGpsData) {
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
}
char altitudeLine[32] = {0};
int32_t alt = geoCoord.getAltitude();
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0fft", alt * METERS_TO_FEET);
} else {
snprintf(altitudeLine, sizeof(altitudeLine), "Alt: %.0im", alt);
}
display->drawString(x, textPos[line++], altitudeLine);
} else {
// Coordinates page needs GPS - say so instead of leaving it blank.
const char *displayLine =
(config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) ? "No GPS" : "GPS off";
int textWidth = display->getStringWidth(displayLine);
display->drawString((SCREEN_WIDTH - textWidth) / 2, textPos[line], displayLine);
}
// Two-page indicator, matching NodeListRenderer::drawScrollbar's thumb style.
const int scrollbarX = SCREEN_WIDTH - 2;
const int thumbHeight = SCREEN_HEIGHT / 2;
const int thumbY = positionViewIndex * (SCREEN_HEIGHT - thumbHeight);
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
graphics::drawCommonFooter(display, x, y);
return;
}
#endif
// If GPS is off or not present (and position isn't fixed), no need to display these parts
if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED || config.position.fixed_position) {
if (hasGpsData) {
// === Second Row: Last GPS Fix ===
if (gpsStatus->getLastFixMillis() > 0) {
uint32_t delta = millis() - gpsStatus->getLastFixMillis();
@@ -1751,6 +2034,12 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta
static int16_t lastFrameIndex = -1;
static uint32_t lastFrameChangeTime = 0;
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
constexpr uint32_t ICON_DISPLAY_DURATION_MS_COMPACT = 1000;
void UIRenderer::notifyScreenWoke()
{
lastFrameChangeTime = millis();
}
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
@@ -1776,18 +2065,79 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
const int spacing = (currentResolution == ScreenResolution::High) ? 8 : 4;
const int bigOffset = (currentResolution == ScreenResolution::High) ? 1 : 0;
const bool compactPanel = graphics::isCompactPanel(display);
const size_t totalIcons = screen->indicatorIcons.size();
if (totalIcons == 0)
return;
const int navPadding = (currentResolution == ScreenResolution::High) ? 24 : 12; // padding per side
// Compact panels: briefly show current frame's icon+title centered, then nothing.
#if defined(OLED_COMPACT_UI)
if (compactPanel) {
const bool introVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS_COMPACT;
if (!introVisible)
return;
const uint8_t *icon =
frameToHighlight < screen->indicatorIcons.size() ? screen->indicatorIcons[frameToHighlight] : nullptr;
const char *title = frameToHighlight < screen->frameTitles.size() ? screen->frameTitles[frameToHighlight] : "";
// Favorite frames: show "Fav: <name>" instead of the generic "Favorite" label.
char favTitleBuf[24];
if (title && strcmp(title, "Favorite") == 0) {
const int favNodeIndex = static_cast<int>(frameToHighlight) -
(static_cast<int>(screen->frameCount) - static_cast<int>(favoritedNodes.size()));
if (favNodeIndex >= 0 && favNodeIndex < (int)favoritedNodes.size() && favoritedNodes[favNodeIndex]) {
meshtastic_NodeInfoLite *favNode = favoritedNodes[favNodeIndex];
const char *favShort = (nodeInfoLiteHasUser(favNode) && favNode->short_name[0]) ? favNode->short_name : "Node";
const char *favRaw = (nodeInfoLiteHasUser(favNode) && favNode->long_name[0]) ? favNode->long_name : favShort;
char favLine[40];
snprintf(favLine, sizeof(favLine), "Fav: %s", favRaw);
UIRenderer::truncateStringWithEmotes(display, favLine, favTitleBuf, sizeof(favTitleBuf), SCREEN_WIDTH - 4);
title = favTitleBuf;
}
}
constexpr int introIconSize = 16;
display->setFont(FONT_SMALL);
const int blockHeight = introIconSize + 2 + FONT_HEIGHT_SMALL;
const int top = (SCREEN_HEIGHT - blockHeight) / 2;
const int iconX = (SCREEN_WIDTH - introIconSize) / 2;
display->setColor(BLACK);
display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
display->setColor(WHITE);
// Battery level in the top-right corner while the intro is showing.
char battStr[8];
int chargePercent = powerStatus->getBatteryChargePercent();
if (chargePercent == 101) {
snprintf(battStr, sizeof(battStr), "USB");
} else {
snprintf(battStr, sizeof(battStr), "%s%d%%", powerStatus->getIsCharging() ? "+" : "", chargePercent);
}
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(SCREEN_WIDTH, 0, battStr);
display->setTextAlignment(TEXT_ALIGN_LEFT);
if (icon)
NodeListRenderer::drawScaledXBitmap16x16(iconX, top, 8, 8, icon, display);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(SCREEN_WIDTH / 2, top + introIconSize + 2, title);
display->setTextAlignment(TEXT_ALIGN_LEFT);
return;
}
#endif
const int navPadding = compactPanel ? 8 : ((currentResolution == ScreenResolution::High) ? 24 : 12);
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
if (usableWidth < iconSize)
usableWidth = iconSize;
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
const size_t iconsPerPage =
compactPanel ? ((usableWidth + spacing) / (iconSize + spacing)) : (usableWidth / (iconSize + spacing));
const size_t currentPage = frameToHighlight / iconsPerPage;
const size_t pageStart = currentPage * iconsPerPage;
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
@@ -1825,11 +2175,14 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
navBarPrevVisible = navBarVisible;
#endif
if (compactPanel && !navBarVisible)
return;
// Pre-calculate bounding rect
const int rectX = xStart - 2 - bigOffset;
const int rectY = y - 2;
const int rectY = y - (compactPanel ? 1 : 2);
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
const int rectHeight = iconSize + 6;
const int rectHeight = iconSize + (compactPanel ? 2 : 6);
// Clear background and draw border
display->setColor(BLACK);
@@ -1871,8 +2224,9 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2);
display->setColor(BLACK);
#else
const int activePadding = compactPanel ? 1 : 2;
display->setColor(WHITE);
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
display->fillRect(x - activePadding, y - activePadding, iconSize + activePadding * 2, iconSize + activePadding * 2);
display->setColor(BLACK);
#endif
}