mirror of
https://github.com/meshtastic/firmware.git
synced 2026-05-30 11:45:09 -04:00
* True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager) * Theme support - New and some Classic Themes! * Colored Compass --------- Co-authored-by: Jason P <applewiz@mac.com> Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz> Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2031 lines
85 KiB
C++
2031 lines
85 KiB
C++
#include "configuration.h"
|
|
#if HAS_SCREEN
|
|
#include "CompassRenderer.h"
|
|
#include "GPSStatus.h"
|
|
#include "MeshService.h"
|
|
#include "NodeDB.h"
|
|
#include "NodeListRenderer.h"
|
|
#if !MESHTASTIC_EXCLUDE_STATUS
|
|
#include "modules/StatusMessageModule.h"
|
|
#endif
|
|
#include "UIRenderer.h"
|
|
#include "airtime.h"
|
|
#include "gps/GeoCoord.h"
|
|
#include "graphics/EmoteRenderer.h"
|
|
#include "graphics/SharedUIDisplay.h"
|
|
#include "graphics/TFTColorRegions.h"
|
|
#include "graphics/TFTPalette.h"
|
|
#include "graphics/TimeFormatters.h"
|
|
#include "graphics/images.h"
|
|
#include "main.h"
|
|
#include "target_specific.h"
|
|
#include <OLEDDisplay.h>
|
|
#include <RTC.h>
|
|
#include <cstring>
|
|
|
|
// External variables
|
|
extern graphics::Screen *screen;
|
|
#if defined(M5STACK_UNITC6L)
|
|
static uint32_t lastSwitchTime = 0;
|
|
#endif
|
|
namespace graphics
|
|
{
|
|
NodeNum UIRenderer::currentFavoriteNodeNum = 0;
|
|
std::vector<meshtastic_NodeInfoLite *> graphics::UIRenderer::favoritedNodes;
|
|
static bool gBootSplashBoldPass = false;
|
|
|
|
static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
|
|
{
|
|
int yOffset = (currentResolution == ScreenResolution::High) ? -5 : 1;
|
|
if (currentResolution == ScreenResolution::High) {
|
|
NodeListRenderer::drawScaledXBitmap16x16(x, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
|
} else {
|
|
display->drawXbm(x + 1, y + yOffset, imgSatellite_width, imgSatellite_height, imgSatellite);
|
|
}
|
|
}
|
|
|
|
struct StandardCompassNeedlePoints {
|
|
int16_t northTipX;
|
|
int16_t northTipY;
|
|
int16_t northLeftX;
|
|
int16_t northLeftY;
|
|
int16_t northRightX;
|
|
int16_t northRightY;
|
|
int16_t southTipX;
|
|
int16_t southTipY;
|
|
int16_t southLeftX;
|
|
int16_t southLeftY;
|
|
int16_t southRightX;
|
|
int16_t southRightY;
|
|
};
|
|
|
|
static inline void swapPoint(int16_t &ax, int16_t &ay, int16_t &bx, int16_t &by)
|
|
{
|
|
const int16_t tx = ax;
|
|
const int16_t ty = ay;
|
|
ax = bx;
|
|
ay = by;
|
|
bx = tx;
|
|
by = ty;
|
|
}
|
|
|
|
static inline void transformNeedlePoint(float localX, float localY, float sinHeading, float cosHeading, float scale,
|
|
int16_t centerX, int16_t centerY, int16_t &outX, int16_t &outY)
|
|
{
|
|
const float x = ((localX * cosHeading) - (localY * sinHeading)) * scale + centerX;
|
|
const float y = ((localX * sinHeading) + (localY * cosHeading)) * scale + centerY;
|
|
outX = static_cast<int16_t>(x);
|
|
outY = static_cast<int16_t>(y);
|
|
}
|
|
|
|
static float getCompassRingAngleOffset(float heading)
|
|
{
|
|
return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
|
|
}
|
|
|
|
static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY,
|
|
uint16_t compassDiam, float headingRadian,
|
|
float centerGapPx)
|
|
{
|
|
// Standard-style symmetric needle with a narrow waist and a tiny center gap
|
|
// between north/south halves to prevent seam bleed while rotating.
|
|
const float scaledDiam = compassDiam * 0.76f;
|
|
const float gapNormHalf = (centerGapPx * 0.5f) / scaledDiam;
|
|
const float sinHeading = sinf(headingRadian);
|
|
const float cosHeading = cosf(headingRadian);
|
|
|
|
StandardCompassNeedlePoints points{};
|
|
transformNeedlePoint(0.0f, -0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northTipX, points.northTipY);
|
|
transformNeedlePoint(-0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northLeftX,
|
|
points.northLeftY);
|
|
transformNeedlePoint(0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northRightX,
|
|
points.northRightY);
|
|
transformNeedlePoint(0.0f, 0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southTipX, points.southTipY);
|
|
transformNeedlePoint(-0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southLeftX,
|
|
points.southLeftY);
|
|
transformNeedlePoint(0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southRightX,
|
|
points.southRightY);
|
|
return points;
|
|
}
|
|
|
|
static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
|
float heading)
|
|
{
|
|
int16_t labelRadius = compassRadius;
|
|
// CompassRenderer::drawCompassNorth() expands radius on high-res by +4.
|
|
// Compensate so label placement stays aligned with the current UI layout.
|
|
if (currentResolution == ScreenResolution::High && labelRadius > 4) {
|
|
labelRadius -= 4;
|
|
}
|
|
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)
|
|
{
|
|
const StandardCompassNeedlePoints points =
|
|
computeStandardCompassNeedlePoints(compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, 0.0f);
|
|
|
|
#ifdef USE_EINK
|
|
display->setColor(WHITE);
|
|
display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
|
|
points.northRightY);
|
|
display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
|
|
points.southRightY);
|
|
#else
|
|
// OLED variant: same needle geometry as TFT, but monochrome contrast.
|
|
display->setColor(WHITE);
|
|
display->fillTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
|
|
points.northRightY);
|
|
display->setColor(BLACK);
|
|
display->fillTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
|
|
points.southRightY);
|
|
// Keep a white outline so the black half remains visible on dark backgrounds.
|
|
display->setColor(WHITE);
|
|
display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
|
|
points.southRightY);
|
|
#endif
|
|
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading);
|
|
}
|
|
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
struct NeedleColorBand {
|
|
int16_t xMin;
|
|
int16_t xMax;
|
|
int16_t yMin;
|
|
int16_t yMax;
|
|
bool used;
|
|
};
|
|
|
|
static constexpr int kNeedleBandCount = 6;
|
|
|
|
static inline void registerNeedleSpan(NeedleColorBand (&bands)[kNeedleBandCount], int16_t bandTop, int16_t bandHeight, int16_t y,
|
|
int16_t a, int16_t b)
|
|
{
|
|
if (a > b) {
|
|
const int16_t t = a;
|
|
a = b;
|
|
b = t;
|
|
}
|
|
|
|
int band = (static_cast<int32_t>(y - bandTop) * kNeedleBandCount) / bandHeight;
|
|
if (band < 0) {
|
|
band = 0;
|
|
} else if (band >= kNeedleBandCount) {
|
|
band = kNeedleBandCount - 1;
|
|
}
|
|
|
|
NeedleColorBand ®ion = bands[band];
|
|
if (!region.used) {
|
|
region.used = true;
|
|
region.xMin = a;
|
|
region.xMax = b;
|
|
region.yMin = y;
|
|
region.yMax = y;
|
|
return;
|
|
}
|
|
if (a < region.xMin)
|
|
region.xMin = a;
|
|
if (b > region.xMax)
|
|
region.xMax = b;
|
|
if (y < region.yMin)
|
|
region.yMin = y;
|
|
if (y > region.yMax)
|
|
region.yMax = y;
|
|
}
|
|
|
|
static void drawNeedleHalfAndRegisterBands(OLEDDisplay *display, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2,
|
|
int16_t y2, uint16_t onColor, uint16_t offColor)
|
|
{
|
|
// Important for maintainers:
|
|
// The compass needle rotates continuously, so color-region registration must
|
|
// track triangle shape (or a close approximation), not only one AABB.
|
|
// Coarse rectangles can leak south color into north at diagonal angles.
|
|
// Keep this banded approach unless a replacement preserves per-angle coverage.
|
|
// Performance note: draw the triangle once via fillTriangle(), then build
|
|
// band regions in software for accurate color-role registration.
|
|
display->fillTriangle(x0, y0, x1, y1, x2, y2);
|
|
|
|
if (y0 > y1)
|
|
swapPoint(x0, y0, x1, y1);
|
|
if (y1 > y2)
|
|
swapPoint(x1, y1, x2, y2);
|
|
if (y0 > y1)
|
|
swapPoint(x0, y0, x1, y1);
|
|
|
|
NeedleColorBand bands[kNeedleBandCount] = {};
|
|
|
|
const int16_t bandTop = y0;
|
|
const int16_t bandBottom = y2;
|
|
const int16_t bandHeight = (bandBottom >= bandTop) ? static_cast<int16_t>(bandBottom - bandTop + 1) : 1;
|
|
|
|
const int32_t dx01 = x1 - x0;
|
|
const int32_t dy01 = y1 - y0;
|
|
const int32_t dx02 = x2 - x0;
|
|
const int32_t dy02 = y2 - y0;
|
|
const int32_t dx12 = x2 - x1;
|
|
const int32_t dy12 = y2 - y1;
|
|
|
|
int32_t sa = 0;
|
|
int32_t sb = 0;
|
|
int16_t y = y0;
|
|
|
|
const int16_t last = (y1 == y2) ? y1 : static_cast<int16_t>(y1 - 1);
|
|
for (; y <= last; y++) {
|
|
const int16_t a = static_cast<int16_t>(x0 + ((dy01 != 0) ? (sa / dy01) : 0));
|
|
const int16_t b = static_cast<int16_t>(x0 + ((dy02 != 0) ? (sb / dy02) : 0));
|
|
sa += dx01;
|
|
sb += dx02;
|
|
registerNeedleSpan(bands, bandTop, bandHeight, y, a, b);
|
|
}
|
|
|
|
sa = dx12 * static_cast<int32_t>(y - y1);
|
|
sb = dx02 * static_cast<int32_t>(y - y0);
|
|
for (; y <= y2; y++) {
|
|
const int16_t a = static_cast<int16_t>(x1 + ((dy12 != 0) ? (sa / dy12) : 0));
|
|
const int16_t b = static_cast<int16_t>(x0 + ((dy02 != 0) ? (sb / dy02) : 0));
|
|
sa += dx12;
|
|
sb += dx02;
|
|
registerNeedleSpan(bands, bandTop, bandHeight, y, a, b);
|
|
}
|
|
|
|
for (int i = 0; i < kNeedleBandCount; i++) {
|
|
if (!bands[i].used)
|
|
continue;
|
|
registerTFTColorRegionDirect(bands[i].xMin, bands[i].yMin, bands[i].xMax - bands[i].xMin + 1,
|
|
bands[i].yMax - bands[i].yMin + 1, onColor, offColor);
|
|
}
|
|
}
|
|
|
|
static inline void drawCompassCardinalLabel(OLEDDisplay *display, int16_t x, int16_t y, const char *label, int16_t textWidth)
|
|
{
|
|
const int16_t labelTop = y - (FONT_HEIGHT_SMALL / 2);
|
|
const int16_t padX = 1;
|
|
const int16_t padY = 1;
|
|
|
|
// Clear any ring/tick pixels behind the label so letters remain clean.
|
|
display->setColor(BLACK);
|
|
display->fillRect(x - (textWidth / 2) - padX, labelTop - padY, textWidth + (padX * 2), FONT_HEIGHT_SMALL + (padY * 2));
|
|
|
|
display->setColor(WHITE);
|
|
display->drawString(x, labelTop, label);
|
|
}
|
|
|
|
static inline void drawCompassCardinalLabels(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
|
float heading)
|
|
{
|
|
const float northAngle = getCompassRingAngleOffset(heading);
|
|
const float radius = compassRadius - 1.0f;
|
|
const float sinNorth = sinf(northAngle);
|
|
const float cosNorth = cosf(northAngle);
|
|
|
|
const int16_t nX = compassX + static_cast<int16_t>(radius * sinNorth);
|
|
const int16_t nY = compassY - static_cast<int16_t>(radius * cosNorth);
|
|
const int16_t eX = compassX + static_cast<int16_t>(radius * cosNorth);
|
|
const int16_t eY = compassY + static_cast<int16_t>(radius * sinNorth);
|
|
const int16_t sX = compassX - static_cast<int16_t>(radius * sinNorth);
|
|
const int16_t sY = compassY + static_cast<int16_t>(radius * cosNorth);
|
|
const int16_t wX = compassX - static_cast<int16_t>(radius * cosNorth);
|
|
const int16_t wY = compassY - static_cast<int16_t>(radius * sinNorth);
|
|
|
|
display->setFont(FONT_SMALL);
|
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
const int16_t labelWidth = static_cast<int16_t>(display->getStringWidth("N"));
|
|
drawCompassCardinalLabel(display, nX, nY, "N", labelWidth);
|
|
drawCompassCardinalLabel(display, eX, eY, "E", labelWidth);
|
|
drawCompassCardinalLabel(display, sX, sY, "S", labelWidth);
|
|
drawCompassCardinalLabel(display, wX, wY, "W", labelWidth);
|
|
}
|
|
|
|
static inline void drawCompassDegreeMarkers(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
|
float heading)
|
|
{
|
|
const float baseAngle = getCompassRingAngleOffset(heading);
|
|
|
|
constexpr int16_t majorLen = 5;
|
|
constexpr int16_t minorLen = 3;
|
|
|
|
display->setColor(WHITE);
|
|
constexpr float kStepAngle = 15.0f * DEG_TO_RAD;
|
|
const float sinStep = sinf(kStepAngle);
|
|
const float cosStep = cosf(kStepAngle);
|
|
float sinAngle = sinf(baseAngle);
|
|
float cosAngle = cosf(baseAngle);
|
|
bool isMajor = true;
|
|
for (int tick = 0; tick < 24; tick++) {
|
|
const int16_t tickLen = isMajor ? majorLen : minorLen;
|
|
|
|
const int16_t xOuter = compassX + static_cast<int16_t>((compassRadius - 1) * sinAngle);
|
|
const int16_t yOuter = compassY - static_cast<int16_t>((compassRadius - 1) * cosAngle);
|
|
const int16_t xInner = compassX + static_cast<int16_t>((compassRadius - tickLen) * sinAngle);
|
|
const int16_t yInner = compassY - static_cast<int16_t>((compassRadius - tickLen) * cosAngle);
|
|
display->drawLine(xInner, yInner, xOuter, yOuter);
|
|
|
|
// Rotate [sin, cos] by a fixed step instead of recomputing trig 24x/frame.
|
|
const float nextSin = (sinAngle * cosStep) + (cosAngle * sinStep);
|
|
const float nextCos = (cosAngle * cosStep) - (sinAngle * sinStep);
|
|
sinAngle = nextSin;
|
|
cosAngle = nextCos;
|
|
isMajor = !isMajor;
|
|
}
|
|
}
|
|
|
|
static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam,
|
|
float headingRadian, uint16_t needleOffColor)
|
|
{
|
|
const StandardCompassNeedlePoints points =
|
|
computeStandardCompassNeedlePoints(compassX, compassY, compassDiam, headingRadian, 9.0f);
|
|
|
|
display->setColor(WHITE);
|
|
#ifdef USE_EINK
|
|
display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
|
|
points.northRightY);
|
|
display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
|
|
points.southRightY);
|
|
#else
|
|
// NOTE: do not collapse these to one region per half during "flash
|
|
// optimization". The needle spins, and coarse rectangles will bleed color
|
|
// across halves at diagonal angles.
|
|
drawNeedleHalfAndRegisterBands(display, points.northTipX, points.northTipY, points.northLeftX, points.northLeftY,
|
|
points.northRightX, points.northRightY, TFTPalette::Red, needleOffColor);
|
|
drawNeedleHalfAndRegisterBands(display, points.southTipX, points.southTipY, points.southLeftX, points.southLeftY,
|
|
points.southRightX, points.southRightY, TFTPalette::Blue, needleOffColor);
|
|
#endif
|
|
}
|
|
|
|
static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
|
|
{
|
|
// Compass colors should follow whatever background role is already active at this location.
|
|
const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg());
|
|
const uint16_t compassGlyphColor = TFTPalette::pickReadableMonoFg(compassBgColor);
|
|
const int16_t pad = 2;
|
|
const int16_t labelPadX = static_cast<int16_t>(display->getStringWidth("W") / 2) + 2;
|
|
const int16_t labelPadY = static_cast<int16_t>(FONT_HEIGHT_SMALL / 2) + 2;
|
|
const int16_t boxX = compassX - compassRadius - pad - labelPadX;
|
|
const int16_t boxY = compassY - compassRadius - pad - labelPadY;
|
|
const int16_t boxW = (compassRadius * 2) + (pad * 2) + 1 + (labelPadX * 2);
|
|
const int16_t boxH = (compassRadius * 2) + (pad * 2) + 1 + (labelPadY * 2);
|
|
// Never let compass-local tint regions override the header role regions.
|
|
const int16_t bodyTop = static_cast<int16_t>(getTextPositions(display)[1]);
|
|
int16_t clippedY = boxY;
|
|
int16_t clippedH = boxH;
|
|
if (clippedY < bodyTop) {
|
|
clippedH = static_cast<int16_t>(clippedH - (bodyTop - clippedY));
|
|
clippedY = bodyTop;
|
|
}
|
|
if (clippedH > 0) {
|
|
registerTFTColorRegionDirect(boxX, clippedY, boxW, clippedH, compassGlyphColor, compassBgColor);
|
|
}
|
|
|
|
drawStandardCompassNeedle(display, compassX, compassY, static_cast<uint16_t>(compassRadius * 2), -heading, compassBgColor);
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading);
|
|
drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading);
|
|
}
|
|
#endif // GRAPHICS_TFT_COLORING_ENABLED
|
|
|
|
static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1,
|
|
const char *statusLine2)
|
|
{
|
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
display->drawString(compassX, compassY - FONT_HEIGHT_SMALL, statusLine1);
|
|
display->drawString(compassX, compassY, statusLine2);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
if (showCompass) {
|
|
CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
|
|
CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
|
|
} else {
|
|
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
|
}
|
|
}
|
|
|
|
static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
|
|
bool validHeading, float heading, const char *statusLine1, const char *statusLine2)
|
|
{
|
|
// 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);
|
|
#else
|
|
drawMonoCompass(display, compassX, compassY, compassRadius, heading);
|
|
#endif
|
|
} else {
|
|
display->drawCircle(compassX, compassY, compassRadius);
|
|
drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
|
|
}
|
|
}
|
|
|
|
static bool computeLandscapeCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t topY, int16_t *compassX,
|
|
int16_t *compassY, int16_t *compassRadius)
|
|
{
|
|
// Keep compass vertically centered in the body area while reserving footer/nav space.
|
|
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
|
|
const int16_t usableHeight = bottomY - topY - 5;
|
|
int16_t radius = usableHeight / 2;
|
|
if (radius < 8) {
|
|
radius = 8;
|
|
}
|
|
|
|
*compassRadius = radius;
|
|
*compassX = xOffset + SCREEN_WIDTH - radius - 8;
|
|
*compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
|
return true;
|
|
}
|
|
|
|
static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t yBelowContent, int16_t bottomReserved,
|
|
int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius)
|
|
{
|
|
// Return false when content leaves no room for a readable compass.
|
|
int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin;
|
|
if (availableHeight < FONT_HEIGHT_SMALL * 2) {
|
|
return false;
|
|
}
|
|
|
|
int16_t radius = static_cast<int16_t>(availableHeight / 2);
|
|
if (radius < 8) {
|
|
radius = 8;
|
|
}
|
|
if (radius * 2 > SCREEN_WIDTH - 16) {
|
|
radius = (SCREEN_WIDTH - 16) / 2;
|
|
}
|
|
|
|
*compassRadius = radius;
|
|
*compassX = xOffset + (SCREEN_WIDTH / 2);
|
|
*compassY = static_cast<int16_t>(yBelowContent + (availableHeight / 2));
|
|
return true;
|
|
}
|
|
|
|
static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText)
|
|
{
|
|
// Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down.
|
|
char rawStatus[96];
|
|
snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str());
|
|
|
|
char clippedStatus[96];
|
|
UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth());
|
|
display->drawString(x, y, clippedStatus);
|
|
}
|
|
|
|
static int computeChannelUtilizationFill(int percent, int maxFill)
|
|
{
|
|
// Compact linear fill mapping for the utilization bar.
|
|
if (percent <= 0 || maxFill <= 0) {
|
|
return 0;
|
|
}
|
|
if (percent >= 100) {
|
|
return maxFill;
|
|
}
|
|
return (maxFill * percent + 50) / 100;
|
|
}
|
|
|
|
void graphics::UIRenderer::rebuildFavoritedNodes()
|
|
{
|
|
favoritedNodes.clear();
|
|
size_t total = nodeDB->getNumMeshNodes();
|
|
for (size_t i = 0; i < total; i++) {
|
|
meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
|
if (!n || n->num == nodeDB->getNodeNum())
|
|
continue;
|
|
if (n->is_favorite)
|
|
favoritedNodes.push_back(n);
|
|
}
|
|
|
|
std::sort(favoritedNodes.begin(), favoritedNodes.end(),
|
|
[](const meshtastic_NodeInfoLite *a, const meshtastic_NodeInfoLite *b) { return a->num < b->num; });
|
|
}
|
|
|
|
#if !MESHTASTIC_EXCLUDE_GPS
|
|
// GeoCoord object for coordinate conversions
|
|
extern GeoCoord geoCoord;
|
|
|
|
// Threshold values for the GPS lock accuracy bar display
|
|
extern uint32_t dopThresholds[5];
|
|
|
|
// Draw GPS status summary
|
|
void UIRenderer::drawGps(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
|
{
|
|
// Draw satellite image
|
|
if (currentResolution == ScreenResolution::High) {
|
|
NodeListRenderer::drawScaledXBitmap16x16(x, y - 2, imgSatellite_width, imgSatellite_height, imgSatellite, display);
|
|
} else {
|
|
display->drawXbm(x + 1, y + 1, imgSatellite_width, imgSatellite_height, imgSatellite);
|
|
}
|
|
char textString[10];
|
|
|
|
if (config.position.fixed_position) {
|
|
// GPS coordinates are currently fixed
|
|
snprintf(textString, sizeof(textString), "Fixed");
|
|
}
|
|
if (!gps->getIsConnected()) {
|
|
snprintf(textString, sizeof(textString), "No Lock");
|
|
}
|
|
if (!gps->getHasLock()) {
|
|
// Draw "No sats" to the right of the icon with slightly more gap
|
|
snprintf(textString, sizeof(textString), "No Sats");
|
|
} else {
|
|
snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
|
|
}
|
|
if (currentResolution == ScreenResolution::High) {
|
|
display->drawString(x + 18, y, textString);
|
|
} else {
|
|
display->drawString(x + 11, y, textString);
|
|
}
|
|
}
|
|
|
|
// Draw status when GPS is disabled or not present
|
|
void UIRenderer::drawGpsPowerStatus(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
|
{
|
|
const char *displayLine;
|
|
int pos;
|
|
if (y < FONT_HEIGHT_SMALL) { // Line 1: use short string
|
|
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
|
pos = display->getWidth() - display->getStringWidth(displayLine);
|
|
} else {
|
|
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "GPS not present"
|
|
: "GPS is disabled";
|
|
pos = (display->getWidth() - display->getStringWidth(displayLine)) / 2;
|
|
}
|
|
display->drawString(x + pos, y, displayLine);
|
|
}
|
|
|
|
void UIRenderer::drawGpsAltitude(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps)
|
|
{
|
|
char displayLine[32];
|
|
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
|
// displayLine = "No GPS Module";
|
|
// display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
|
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
|
// displayLine = "No GPS Lock";
|
|
// display->drawString(x + (SCREEN_WIDTH - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
|
} else {
|
|
geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude()));
|
|
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL)
|
|
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0fft", geoCoord.getAltitude() * METERS_TO_FEET);
|
|
else
|
|
snprintf(displayLine, sizeof(displayLine), "Altitude: %.0im", geoCoord.getAltitude());
|
|
display->drawString(x + (display->getWidth() - (display->getStringWidth(displayLine))) / 2, y, displayLine);
|
|
}
|
|
}
|
|
|
|
// Draw GPS status coordinates
|
|
void UIRenderer::drawGpsCoordinates(OLEDDisplay *display, int16_t x, int16_t y, const meshtastic::GPSStatus *gps,
|
|
const char *mode)
|
|
{
|
|
auto gpsFormat = uiconfig.gps_format;
|
|
char displayLine[32];
|
|
|
|
if (!gps->getIsConnected() && !config.position.fixed_position) {
|
|
if (strcmp(mode, "line1") == 0) {
|
|
strcpy(displayLine, "No GPS present");
|
|
display->drawString(x, y, displayLine);
|
|
}
|
|
} else if (!gps->getHasLock() && !config.position.fixed_position) {
|
|
if (strcmp(mode, "line1") == 0) {
|
|
strcpy(displayLine, "No GPS Lock");
|
|
display->drawString(x, y, displayLine);
|
|
}
|
|
} else {
|
|
|
|
geoCoord.updateCoords(int32_t(gps->getLatitude()), int32_t(gps->getLongitude()), int32_t(gps->getAltitude()));
|
|
|
|
if (gpsFormat != meshtastic_DeviceUIConfig_GpsCoordinateFormat_DMS) {
|
|
char coordinateLine_1[22];
|
|
char coordinateLine_2[22];
|
|
if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_DEC) { // Decimal Degrees
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "Lat: %f", geoCoord.getLatitude() * 1e-7);
|
|
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %f", geoCoord.getLongitude() * 1e-7);
|
|
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_UTM) { // Universal Transverse Mercator
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%2i%1c %06u E", geoCoord.getUTMZone(),
|
|
geoCoord.getUTMBand(), geoCoord.getUTMEasting());
|
|
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%07u N", geoCoord.getUTMNorthing());
|
|
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_MGRS) { // Military Grid Reference System
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%2i%1c %1c%1c", geoCoord.getMGRSZone(),
|
|
geoCoord.getMGRSBand(), geoCoord.getMGRSEast100k(), geoCoord.getMGRSNorth100k());
|
|
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%05u E %05u N", geoCoord.getMGRSEasting(),
|
|
geoCoord.getMGRSNorthing());
|
|
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC) { // Open Location Code
|
|
geoCoord.getOLCCode(coordinateLine_1);
|
|
coordinateLine_2[0] = '\0';
|
|
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_OSGR) { // Ordnance Survey Grid Reference
|
|
if (geoCoord.getOSGRE100k() == 'I' || geoCoord.getOSGRN100k() == 'I') { // OSGR is only valid around the UK region
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%s", "Out of Boundary");
|
|
coordinateLine_2[0] = '\0';
|
|
} else {
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "%1c%1c", geoCoord.getOSGRE100k(),
|
|
geoCoord.getOSGRN100k());
|
|
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "%05u E %05u N", geoCoord.getOSGREasting(),
|
|
geoCoord.getOSGRNorthing());
|
|
}
|
|
} else if (gpsFormat == meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) { // Maidenhead Locator System
|
|
double lat = geoCoord.getLatitude() * 1e-7;
|
|
double lon = geoCoord.getLongitude() * 1e-7;
|
|
|
|
// Normalize
|
|
if (lat > 90.0)
|
|
lat = 90.0;
|
|
if (lat < -90.0)
|
|
lat = -90.0;
|
|
while (lon < -180.0)
|
|
lon += 360.0;
|
|
while (lon >= 180.0)
|
|
lon -= 360.0;
|
|
|
|
double adjLon = lon + 180.0;
|
|
double adjLat = lat + 90.0;
|
|
|
|
char maiden[10]; // enough for 8-char + null
|
|
|
|
// Field (2 letters)
|
|
int lonField = int(adjLon / 20.0);
|
|
int latField = int(adjLat / 10.0);
|
|
adjLon -= lonField * 20.0;
|
|
adjLat -= latField * 10.0;
|
|
|
|
// Square (2 digits)
|
|
int lonSquare = int(adjLon / 2.0);
|
|
int latSquare = int(adjLat / 1.0);
|
|
adjLon -= lonSquare * 2.0;
|
|
adjLat -= latSquare * 1.0;
|
|
|
|
// Subsquare (2 letters)
|
|
double lonUnit = 2.0 / 24.0;
|
|
double latUnit = 1.0 / 24.0;
|
|
int lonSub = int(adjLon / lonUnit);
|
|
int latSub = int(adjLat / latUnit);
|
|
|
|
snprintf(maiden, sizeof(maiden), "%c%c%c%c%c%c", 'A' + lonField, 'A' + latField, '0' + lonSquare, '0' + latSquare,
|
|
'A' + lonSub, 'A' + latSub);
|
|
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "MH: %s", maiden);
|
|
coordinateLine_2[0] = '\0'; // only need one line
|
|
}
|
|
|
|
if (strcmp(mode, "line1") == 0) {
|
|
display->drawString(x, y, coordinateLine_1);
|
|
} else if (strcmp(mode, "line2") == 0) {
|
|
display->drawString(x, y, coordinateLine_2);
|
|
} else if (strcmp(mode, "combined") == 0) {
|
|
display->drawString(x, y, coordinateLine_1);
|
|
if (coordinateLine_2[0] != '\0') {
|
|
display->drawString(x + display->getStringWidth(coordinateLine_1), y, coordinateLine_2);
|
|
}
|
|
}
|
|
|
|
} else {
|
|
char coordinateLine_1[22];
|
|
char coordinateLine_2[22];
|
|
snprintf(coordinateLine_1, sizeof(coordinateLine_1), "Lat: %2i° %2i' %2u\" %1c", geoCoord.getDMSLatDeg(),
|
|
geoCoord.getDMSLatMin(), geoCoord.getDMSLatSec(), geoCoord.getDMSLatCP());
|
|
snprintf(coordinateLine_2, sizeof(coordinateLine_2), "Lon: %3i° %2i' %2u\" %1c", geoCoord.getDMSLonDeg(),
|
|
geoCoord.getDMSLonMin(), geoCoord.getDMSLonSec(), geoCoord.getDMSLonCP());
|
|
if (strcmp(mode, "line1") == 0) {
|
|
display->drawString(x, y, coordinateLine_1);
|
|
} else if (strcmp(mode, "line2") == 0) {
|
|
display->drawString(x, y, coordinateLine_2);
|
|
} else { // both
|
|
display->drawString(x, y, coordinateLine_1);
|
|
display->drawString(x, y + 10, coordinateLine_2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif // !MESHTASTIC_EXCLUDE_GPS
|
|
|
|
// 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)
|
|
{
|
|
char usersString[20];
|
|
int nodes_online = (nodeStatus->getNumOnline() > 0) ? nodeStatus->getNumOnline() + node_offset : 0;
|
|
|
|
snprintf(usersString, sizeof(usersString), "%d %s", nodes_online, additional_words);
|
|
|
|
if (show_total) {
|
|
int nodes_total = (nodeStatus->getNumTotal() > 0) ? nodeStatus->getNumTotal() + node_offset : 0;
|
|
snprintf(usersString, sizeof(usersString), "%d/%d %s", nodes_online, nodes_total, additional_words);
|
|
}
|
|
|
|
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
|
|
defined(ST7789_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(HX8357_CS) || defined(ST7796_CS) || \
|
|
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796)) && \
|
|
!defined(DISPLAY_FORCE_SMALL_FONTS)
|
|
|
|
if (currentResolution == ScreenResolution::High) {
|
|
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
|
} else {
|
|
display->drawFastImage(x, y + 3, 8, 8, imgUser);
|
|
}
|
|
#else
|
|
if (currentResolution == ScreenResolution::High) {
|
|
NodeListRenderer::drawScaledXBitmap16x16(x, y - 1, 8, 8, imgUser, display);
|
|
} else {
|
|
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 *
|
|
// **********************
|
|
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
|
|
void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
if (favoritedNodes.empty())
|
|
return;
|
|
|
|
// --- Only display if index is valid ---
|
|
int nodeIndex = state->currentFrame - (screen->frameCount - favoritedNodes.size());
|
|
if (nodeIndex < 0 || nodeIndex >= (int)favoritedNodes.size())
|
|
return;
|
|
|
|
meshtastic_NodeInfoLite *node = favoritedNodes[nodeIndex];
|
|
if (!node || node->num == nodeDB->getNodeNum() || !node->is_favorite)
|
|
return;
|
|
display->clear();
|
|
#if defined(M5STACK_UNITC6L)
|
|
uint32_t now = millis();
|
|
if (now - lastSwitchTime >= 10000) // 10000 ms = 10 秒
|
|
{
|
|
display->display();
|
|
lastSwitchTime = now;
|
|
}
|
|
#endif
|
|
currentFavoriteNodeNum = node->num;
|
|
// === Create the shortName and title string ===
|
|
const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
|
|
char titlestr[40];
|
|
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
|
|
|
|
// === Draw battery/time/mail header (common across screens) ===
|
|
graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow);
|
|
|
|
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
|
|
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
|
|
// 2. Each row is only shown if it has valid data.
|
|
// 3. Each row "moves up" if previous are empty, so there are never any blank rows.
|
|
// 4. The first line is ALWAYS at your macro position; subsequent lines use the next available macro slot.
|
|
|
|
// List of available macro Y positions in order, from top to bottom.
|
|
int line = 1; // which slot to use next
|
|
// === 1. Long Name (always try to show first) ===
|
|
const char *username;
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
username = (node->has_user && node->user.long_name[0]) ? node->user.short_name : nullptr;
|
|
} else {
|
|
username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
|
|
}
|
|
|
|
// Print node's long name (e.g. "Backpack Node")
|
|
if (username) {
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username);
|
|
setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black, x,
|
|
getTextPositions(display)[line], usernameWidth, FONT_HEIGHT_SMALL);
|
|
#endif
|
|
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
|
|
}
|
|
|
|
#if !MESHTASTIC_EXCLUDE_STATUS
|
|
// === Optional: Last received StatusMessage line for this node ===
|
|
// Display it directly under the username line (if we have one).
|
|
if (statusMessageModule) {
|
|
const auto &recent = statusMessageModule->getRecentReceived();
|
|
const StatusMessageModule::RecentStatus *found = nullptr;
|
|
|
|
// Search newest-to-oldest
|
|
for (auto it = recent.rbegin(); it != recent.rend(); ++it) {
|
|
if (it->fromNodeId == node->num && !it->statusText.empty()) {
|
|
found = &(*it);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText);
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// === 2. Signal/Hops line (if available) ===
|
|
bool haveSignal = false;
|
|
int bars = 0;
|
|
const char *qualityLabel = nullptr;
|
|
|
|
// Helper to get SNR limit based on modem preset
|
|
auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float {
|
|
switch (preset) {
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
|
|
return -6.0f;
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
|
|
return -5.5f;
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
|
|
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
|
|
return -4.5f;
|
|
default:
|
|
return -6.0f;
|
|
}
|
|
};
|
|
|
|
// Add extra spacing on the left if we have an API connection to account for the common footer icons
|
|
const char *leftSideSpacing =
|
|
graphics::isAPIConnected(service->api_state) ? (currentResolution == ScreenResolution::High ? " " : " ") : " ";
|
|
const bool isZeroHop = node->has_hops_away && node->hops_away == 0;
|
|
|
|
// Signal text/bars are only for direct (zero-hop) nodes with valid SNR.
|
|
if (isZeroHop) {
|
|
float snr = node->snr;
|
|
if (snr > -100 && snr != 0) {
|
|
float snrLimit = getSnrLimit(config.lora.modem_preset);
|
|
// Determine signal quality label and bars using SNR-only grading.
|
|
if (snr > snrLimit + 10) {
|
|
qualityLabel = "Good";
|
|
bars = 4;
|
|
} else if (snr > snrLimit + 6) {
|
|
qualityLabel = "Good";
|
|
bars = 3;
|
|
} else if (snr > snrLimit + 2) {
|
|
qualityLabel = "Good";
|
|
bars = 2;
|
|
} else if (snr > snrLimit - 4) {
|
|
qualityLabel = "Fair";
|
|
bars = 1;
|
|
} else {
|
|
qualityLabel = "Bad";
|
|
bars = 1;
|
|
}
|
|
|
|
haveSignal = true;
|
|
}
|
|
}
|
|
|
|
const bool showHops = node->has_hops_away && node->hops_away > 0;
|
|
|
|
if (haveSignal || showHops) {
|
|
int yPos = getTextPositions(display)[line++];
|
|
int curX = x + display->getStringWidth(leftSideSpacing);
|
|
|
|
// Draw signal quality text for zero-hop nodes when present.
|
|
if (haveSignal && qualityLabel) {
|
|
char signalLabel[20];
|
|
snprintf(signalLabel, sizeof(signalLabel), "Sig:%s", qualityLabel);
|
|
display->drawString(curX, yPos, signalLabel);
|
|
curX += display->getStringWidth(signalLabel) + 4;
|
|
}
|
|
|
|
// Draw signal bars (skip on UltraLow, text only)
|
|
if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) {
|
|
const int kMaxBars = 4;
|
|
if (bars < 1)
|
|
bars = 1;
|
|
if (bars > kMaxBars)
|
|
bars = kMaxBars;
|
|
|
|
int barX = curX;
|
|
|
|
const bool hi = (currentResolution == ScreenResolution::High);
|
|
int barWidth = hi ? 2 : 1;
|
|
int barGap = hi ? 2 : 1;
|
|
int maxBarHeight = FONT_HEIGHT_SMALL - 7;
|
|
if (!hi)
|
|
maxBarHeight -= 1;
|
|
int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2;
|
|
int totalBarsWidth = (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap);
|
|
|
|
uint16_t signalBarsColor = TFTPalette::Good;
|
|
if (qualityLabel && strcmp(qualityLabel, "Fair") == 0) {
|
|
signalBarsColor = TFTPalette::Medium;
|
|
} else if (qualityLabel && strcmp(qualityLabel, "Bad") == 0) {
|
|
signalBarsColor = TFTPalette::Bad;
|
|
}
|
|
setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barX, barY, totalBarsWidth,
|
|
maxBarHeight);
|
|
|
|
for (int bi = 0; bi < kMaxBars; bi++) {
|
|
int barHeight = maxBarHeight * (bi + 1) / kMaxBars;
|
|
if (barHeight < 2)
|
|
barHeight = 2;
|
|
|
|
int bx = barX + bi * (barWidth + barGap);
|
|
int by = barY + maxBarHeight - barHeight;
|
|
|
|
if (bi < bars) {
|
|
display->fillRect(bx, by, barWidth, barHeight);
|
|
} else {
|
|
int baseY = barY + maxBarHeight - 1;
|
|
display->drawHorizontalLine(bx, baseY, barWidth);
|
|
}
|
|
}
|
|
|
|
curX += totalBarsWidth + 2;
|
|
}
|
|
|
|
// Draw hops for non-zero-hop nodes as: number + hop icon.
|
|
// This path is mutually exclusive with the zero-hop signal-bars path above.
|
|
if (showHops) {
|
|
display->drawString(curX, yPos, "Hop:");
|
|
curX += display->getStringWidth("Hop:") + 2;
|
|
|
|
char hopCount[6];
|
|
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
|
|
display->drawString(curX, yPos, hopCount);
|
|
curX += display->getStringWidth(hopCount) + 2;
|
|
|
|
const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2;
|
|
display->drawXbm(curX, iconY, hop_width, hop_height, hop);
|
|
curX += hop_width + 1;
|
|
}
|
|
}
|
|
|
|
// === 3. Heard (last seen, skip if node never seen) ===
|
|
char seenStr[20] = "";
|
|
uint32_t seconds = sinceLastSeen(node);
|
|
if (seconds != 0 && seconds != UINT32_MAX) {
|
|
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
|
|
// Format as "Heard:Xm ago", "Heard:Xh ago", or "Heard:Xd ago"
|
|
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard:?" : "%sHeard:%d%c ago"), leftSideSpacing,
|
|
(days ? days
|
|
: hours ? hours
|
|
: minutes),
|
|
(days ? 'd'
|
|
: hours ? 'h'
|
|
: 'm'));
|
|
}
|
|
if (seenStr[0]) {
|
|
display->drawString(x, getTextPositions(display)[line++], seenStr);
|
|
}
|
|
#if !defined(M5STACK_UNITC6L)
|
|
// === 4. Uptime (only show if metric is present) ===
|
|
char uptimeStr[32] = "";
|
|
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
|
|
char upPrefix[12]; // enough for leftSideSpacing + "Up:"
|
|
snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
|
|
getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr));
|
|
}
|
|
if (uptimeStr[0]) {
|
|
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
|
|
}
|
|
|
|
// === 5. Distance (only if both nodes have GPS position) ===
|
|
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
char distStr[24] = ""; // Make buffer big enough for any string
|
|
bool haveDistance = false;
|
|
|
|
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
|
|
// Use shared meter conversion, then format display units with lightweight integer rounding.
|
|
const float distanceMeters =
|
|
GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i),
|
|
DegD(ourNode->position.latitude_i), DegD(ourNode->position.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 > 0 && feet < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet);
|
|
haveDistance = true;
|
|
} else {
|
|
const int miles = (feet + 2640) / 5280; // rounded to nearest mile
|
|
if (miles > 0 && miles < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, miles);
|
|
haveDistance = true;
|
|
}
|
|
}
|
|
} else {
|
|
const int meters = static_cast<int>(distanceMeters + 0.5f);
|
|
if (meters > 0 && meters < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters);
|
|
haveDistance = true;
|
|
} else {
|
|
const int km = (meters + 500) / 1000; // rounded to nearest km
|
|
if (km > 0 && km < 1000) {
|
|
snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km);
|
|
haveDistance = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (haveDistance && distStr[0]) {
|
|
display->drawString(x, getTextPositions(display)[line++], distStr);
|
|
}
|
|
|
|
// === 6. Battery after Distance line, otherwise next available line ===
|
|
char batLine[32] = "";
|
|
bool haveBatLine = false;
|
|
|
|
if (node->has_device_metrics) {
|
|
bool hasPct = node->device_metrics.has_battery_level;
|
|
bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f;
|
|
|
|
int pct = 0;
|
|
float volt = 0.0f;
|
|
|
|
if (hasPct) {
|
|
pct = (int)node->device_metrics.battery_level;
|
|
}
|
|
|
|
if (hasVolt) {
|
|
volt = node->device_metrics.voltage;
|
|
}
|
|
|
|
if (hasPct && pct > 0 && pct <= 100) {
|
|
// Normal battery percentage
|
|
if (hasVolt) {
|
|
snprintf(batLine, sizeof(batLine), "%sBat:%d%% (%.2fV)", leftSideSpacing, pct, volt);
|
|
} else {
|
|
snprintf(batLine, sizeof(batLine), "%sBat:%d%%", leftSideSpacing, pct);
|
|
}
|
|
haveBatLine = true;
|
|
} else if (hasPct && pct > 100) {
|
|
// Plugged in
|
|
if (hasVolt) {
|
|
snprintf(batLine, sizeof(batLine), "%sPlugged In (%.2fV)", leftSideSpacing, volt);
|
|
} else {
|
|
snprintf(batLine, sizeof(batLine), "%sPlugged In", leftSideSpacing);
|
|
}
|
|
haveBatLine = true;
|
|
} else if (!hasPct && hasVolt) {
|
|
// Voltage only
|
|
snprintf(batLine, sizeof(batLine), "%sBat:%.2fV", leftSideSpacing, volt);
|
|
haveBatLine = true;
|
|
}
|
|
}
|
|
|
|
const int maxTextLines = (currentResolution == ScreenResolution::High) ? 6 : 5;
|
|
|
|
// Only draw battery if it fits within the allowed lines
|
|
if (haveBatLine && line <= maxTextLines) {
|
|
display->drawString(x, getTextPositions(display)[line++], batLine);
|
|
}
|
|
|
|
bool showCompass = false;
|
|
float myHeading = 0.0f;
|
|
float bearing = 0.0f;
|
|
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
|
const bool hasNodePositionFix = nodeDB->hasValidPosition(node);
|
|
const char *statusLine1 = nullptr;
|
|
const char *statusLine2 = nullptr;
|
|
if (hasOwnPositionFix && hasNodePositionFix) {
|
|
const auto &op = ourNode->position;
|
|
showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading);
|
|
if (showCompass) {
|
|
const auto &p = node->position;
|
|
bearing = GeoCoord::bearing(DegD(op.latitude_i), DegD(op.longitude_i), DegD(p.latitude_i), DegD(p.longitude_i));
|
|
bearing = CompassRenderer::adjustBearingForCompassMode(bearing, myHeading);
|
|
} else {
|
|
statusLine1 = "No";
|
|
statusLine2 = "Heading";
|
|
}
|
|
} else if (!hasOwnPositionFix || !hasNodePositionFix) {
|
|
statusLine1 = "No";
|
|
statusLine2 = "Fix";
|
|
}
|
|
|
|
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
|
|
if (showCompass || statusLine1) {
|
|
int16_t compassX = 0;
|
|
int16_t compassY = 0;
|
|
int16_t compassRadius = 0;
|
|
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
|
const int16_t topY = getTextPositions(display)[1];
|
|
computeLandscapeCompassPlacement(display, x, topY, &compassX, &compassY, &compassRadius);
|
|
} else {
|
|
const int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2)
|
|
: getTextPositions(display)[1];
|
|
#if defined(USE_EINK)
|
|
const int iconSize = (currentResolution == ScreenResolution::High) ? 16 : 8;
|
|
const int navBarHeight = iconSize + 6;
|
|
#else
|
|
const int navBarHeight = 0;
|
|
#endif
|
|
if (!computeBottomCompassPlacement(display, x, yBelowContent, navBarHeight, 4, &compassX, &compassY,
|
|
&compassRadius)) {
|
|
return;
|
|
}
|
|
}
|
|
drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1,
|
|
statusLine2);
|
|
}
|
|
#endif
|
|
graphics::drawCommonFooter(display, x, y);
|
|
}
|
|
|
|
// ****************************
|
|
// * Device Focused Screen *
|
|
// ****************************
|
|
void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
display->clear();
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
int line = 1;
|
|
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
|
|
// === Header ===
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
graphics::drawCommonHeader(display, x, y, "Home");
|
|
} else {
|
|
graphics::drawCommonHeader(display, x, y, "");
|
|
}
|
|
|
|
// === Content below header ===
|
|
|
|
// === First Row: Region / Channel Utilization and Uptime ===
|
|
bool origBold = config.display.heading_bold;
|
|
config.display.heading_bold = false;
|
|
|
|
// Display Region and Channel Utilization
|
|
if (currentResolution == ScreenResolution::UltraLow) {
|
|
drawNodes(display, x, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
|
} else {
|
|
drawNodes(display, x + 1, getTextPositions(display)[line] + 2, nodeStatus, -1, false, "online");
|
|
}
|
|
char uptimeStr[32] = "";
|
|
if (currentResolution != ScreenResolution::UltraLow) {
|
|
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
|
|
}
|
|
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
|
|
|
|
// === Second Row: Satellites and Voltage ===
|
|
config.display.heading_bold = false;
|
|
|
|
#if HAS_GPS
|
|
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
const char *displayLine;
|
|
if (config.position.fixed_position) {
|
|
displayLine = "Fixed GPS";
|
|
} else {
|
|
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
|
}
|
|
drawSatelliteIcon(display, x, getTextPositions(display)[line]);
|
|
int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0;
|
|
display->drawString(x + 11 + xOffset, getTextPositions(display)[line], displayLine);
|
|
} else {
|
|
UIRenderer::drawGps(display, 0, getTextPositions(display)[line], gpsStatus);
|
|
}
|
|
#endif
|
|
|
|
#if defined(M5STACK_UNITC6L)
|
|
line += 1;
|
|
|
|
// === Node Identity ===
|
|
int textWidth = 0;
|
|
int nameX = 0;
|
|
const char *shortName = owner.short_name ? owner.short_name : "";
|
|
|
|
// === 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);
|
|
#else
|
|
if (powerStatus->getHasBattery()) {
|
|
char batStr[20];
|
|
int batV = powerStatus->getBatteryVoltageMv() / 1000;
|
|
int batCv = (powerStatus->getBatteryVoltageMv() % 1000) / 10;
|
|
snprintf(batStr, sizeof(batStr), "%01d.%02dV", batV, batCv);
|
|
display->drawString(x + SCREEN_WIDTH - display->getStringWidth(batStr), getTextPositions(display)[line++], batStr);
|
|
} else {
|
|
display->drawString(x + SCREEN_WIDTH - display->getStringWidth("USB"), getTextPositions(display)[line++], "USB");
|
|
}
|
|
|
|
config.display.heading_bold = origBold;
|
|
|
|
// === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) ===
|
|
const char *chUtil = "ChUtil:";
|
|
char chUtilPercentage[10];
|
|
int chutil_percent = static_cast<int>(airTime->channelUtilizationPercent() + 0.5f);
|
|
snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%d%%", chutil_percent);
|
|
|
|
int chUtil_x = (currentResolution == ScreenResolution::High) ? display->getStringWidth(chUtil) + 10
|
|
: display->getStringWidth(chUtil) + 5;
|
|
int chUtil_y = getTextPositions(display)[line] + 3;
|
|
|
|
int chutil_bar_width = (currentResolution == ScreenResolution::High) ? 100 : 50;
|
|
int chutil_bar_max_fill = chutil_bar_width - 2; // Account for border
|
|
if (!config.bluetooth.enabled) {
|
|
#if defined(USE_EINK)
|
|
chutil_bar_width = (currentResolution == ScreenResolution::High) ? 50 : 30;
|
|
#else
|
|
chutil_bar_width = (currentResolution == ScreenResolution::High) ? 80 : 40;
|
|
#endif
|
|
}
|
|
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
|
|
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
|
|
if (!config.bluetooth.enabled) {
|
|
extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 1;
|
|
}
|
|
const int raw_chutil_percent = chutil_percent;
|
|
|
|
// With BT disabled we pin this row left to make room for the extra "BT off" indicator.
|
|
const int starting_position = config.bluetooth.enabled ? x : 0;
|
|
|
|
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
|
|
|
|
// Force 61% or higher to show a full 100% bar, text would still show related percent.
|
|
if (chutil_percent >= 61) {
|
|
chutil_percent = 100;
|
|
}
|
|
|
|
int fillRight = computeChannelUtilizationFill(chutil_percent, chutil_bar_max_fill);
|
|
|
|
// Draw outline
|
|
display->drawRect(starting_position + chUtil_x, chUtil_y, chutil_bar_width, chutil_bar_height);
|
|
|
|
// Fill progress
|
|
if (fillRight > 0) {
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
uint16_t UtilizationFillColor = TFTPalette::Good;
|
|
if (raw_chutil_percent >= 60) {
|
|
UtilizationFillColor = TFTPalette::Bad;
|
|
} else if (raw_chutil_percent >= 35) {
|
|
UtilizationFillColor = TFTPalette::Medium;
|
|
}
|
|
setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black,
|
|
starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
|
#endif
|
|
display->fillRect(starting_position + chUtil_x + 1, chUtil_y + 1, fillRight, chutil_bar_height - 2);
|
|
}
|
|
|
|
display->drawString(starting_position + chUtil_x + chutil_bar_width + extraoffset, getTextPositions(display)[line],
|
|
chUtilPercentage);
|
|
|
|
if (!config.bluetooth.enabled) {
|
|
display->drawString(SCREEN_WIDTH - display->getStringWidth("BT off"), getTextPositions(display)[line], "BT off");
|
|
}
|
|
|
|
line += 1;
|
|
|
|
// === Fourth & Fifth Rows: Node Identity ===
|
|
int textWidth = 0;
|
|
int nameX = 0;
|
|
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
|
|
const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
|
|
const char *shortName = owner.short_name ? owner.short_name : "";
|
|
char combinedName[96];
|
|
if (longName[0] && shortName[0]) {
|
|
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName);
|
|
} else if (longName[0]) {
|
|
strncpy(combinedName, longName, sizeof(combinedName) - 1);
|
|
combinedName[sizeof(combinedName) - 1] = '\0';
|
|
} else {
|
|
strncpy(combinedName, shortName, sizeof(combinedName) - 1);
|
|
combinedName[sizeof(combinedName) - 1] = '\0';
|
|
}
|
|
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
|
|
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
|
|
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
|
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName,
|
|
FONT_HEIGHT_SMALL, 1, false);
|
|
} else {
|
|
// === LongName Centered ===
|
|
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
|
|
nameX = (SCREEN_WIDTH - textWidth) / 2;
|
|
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
|
|
false);
|
|
|
|
// === 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);
|
|
}
|
|
#endif
|
|
graphics::drawCommonFooter(display, x, y);
|
|
}
|
|
|
|
// Start Functions to write date/time to the screen
|
|
// Helper function to check if a year is a leap year
|
|
constexpr bool isLeapYear(int year)
|
|
{
|
|
return (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0));
|
|
}
|
|
|
|
// Array of days in each month (non-leap year)
|
|
const int daysInMonth[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
|
|
|
|
// Fills the buffer with a formatted date/time string and returns pixel width
|
|
int UIRenderer::formatDateTime(char *buf, size_t bufSize, uint32_t rtc_sec, OLEDDisplay *display, bool includeTime)
|
|
{
|
|
int sec = rtc_sec % 60;
|
|
rtc_sec /= 60;
|
|
int min = rtc_sec % 60;
|
|
rtc_sec /= 60;
|
|
int hour = rtc_sec % 24;
|
|
rtc_sec /= 24;
|
|
|
|
int year = 1970;
|
|
while (true) {
|
|
int daysInYear = isLeapYear(year) ? 366 : 365;
|
|
if (rtc_sec >= (uint32_t)daysInYear) {
|
|
rtc_sec -= daysInYear;
|
|
year++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
int month = 0;
|
|
while (month < 12) {
|
|
int dim = daysInMonth[month];
|
|
if (month == 1 && isLeapYear(year))
|
|
dim++;
|
|
if (rtc_sec >= (uint32_t)dim) {
|
|
rtc_sec -= dim;
|
|
month++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
int day = rtc_sec + 1;
|
|
|
|
if (includeTime) {
|
|
snprintf(buf, bufSize, "%04d-%02d-%02d %02d:%02d:%02d", year, month + 1, day, hour, min, sec);
|
|
} else {
|
|
snprintf(buf, bufSize, "%04d-%02d-%02d", year, month + 1, day);
|
|
}
|
|
|
|
return display->getStringWidth(buf);
|
|
}
|
|
|
|
// Check if the display can render a string (detect special chars; emoji)
|
|
bool UIRenderer::haveGlyphs(const char *str)
|
|
{
|
|
#if defined(OLED_PL) || defined(OLED_UA) || defined(OLED_RU) || defined(OLED_CS)
|
|
// Don't want to make any assumptions about custom language support
|
|
return true;
|
|
#endif
|
|
|
|
// Check each character with the lookup function for the OLED library
|
|
// We're not really meant to use this directly..
|
|
bool have = true;
|
|
for (uint16_t i = 0; i < strlen(str); i++) {
|
|
uint8_t result = Screen::customFontTableLookup((uint8_t)str[i]);
|
|
// If font doesn't support a character, it is substituted for ¿
|
|
if (result == 191 && (uint8_t)str[i] != 191) {
|
|
have = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// LOG_DEBUG("haveGlyphs=%d", have);
|
|
return have;
|
|
}
|
|
|
|
#ifdef USE_EINK
|
|
/// Used on eink displays while in deep sleep
|
|
void UIRenderer::drawDeepSleepFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
|
|
// Next frame should use full-refresh, and block while running, else device will sleep before async callback
|
|
EINK_ADD_FRAMEFLAG(display, COSMETIC);
|
|
EINK_ADD_FRAMEFLAG(display, BLOCKING);
|
|
|
|
LOG_DEBUG("Draw deep sleep screen");
|
|
|
|
// Display displayStr on the screen
|
|
graphics::UIRenderer::drawIconScreen("Sleeping", display, state, x, y);
|
|
}
|
|
|
|
/// Used on eink displays when screen updates are paused
|
|
void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState *state)
|
|
{
|
|
LOG_DEBUG("Draw screensaver overlay");
|
|
|
|
EINK_ADD_FRAMEFLAG(display, COSMETIC); // Full refresh for screensaver
|
|
|
|
// Config
|
|
display->setFont(FONT_SMALL);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
const char *pauseText = "Screen Paused";
|
|
const char *idText = owner.short_name;
|
|
const bool useId = (idText && idText[0]);
|
|
constexpr uint8_t padding = 2;
|
|
constexpr uint8_t dividerGap = 1;
|
|
|
|
// Text widths
|
|
const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0;
|
|
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
|
|
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
|
|
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
|
|
|
|
// Flush with bottom
|
|
const int16_t boxLeft = (display->width() / 2) - (boxWidth / 2);
|
|
const int16_t boxTop = display->height() - boxHeight;
|
|
const int16_t boxBottom = display->height() - 1;
|
|
const int16_t idTextLeft = boxLeft + padding;
|
|
const int16_t idTextTop = boxTop + padding;
|
|
const int16_t pauseTextLeft = boxLeft + (useId ? idTextWidth + (padding * 2) : 0) + padding;
|
|
const int16_t pauseTextTop = boxTop + padding;
|
|
const int16_t dividerX = boxLeft + padding + idTextWidth + padding;
|
|
const int16_t dividerTop = boxTop + dividerGap;
|
|
const int16_t dividerBottom = boxBottom - dividerGap;
|
|
|
|
// Draw: box
|
|
display->setColor(EINK_WHITE);
|
|
display->fillRect(boxLeft, boxTop, boxWidth, boxHeight);
|
|
display->setColor(EINK_BLACK);
|
|
display->drawRect(boxLeft, boxTop, boxWidth, boxHeight);
|
|
|
|
// Draw: text
|
|
if (useId)
|
|
UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false);
|
|
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
|
|
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
|
|
|
|
// Draw: divider
|
|
if (useId)
|
|
display->drawLine(dividerX, dividerTop, dividerX, dividerBottom);
|
|
}
|
|
#endif
|
|
|
|
/**
|
|
* Draw the icon with extra info printed around the corners
|
|
*/
|
|
void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
// draw an xbm image.
|
|
// Please note that everything that should be transitioned
|
|
// needs to be drawn relative to x and y
|
|
|
|
// draw centered icon left to right and centered above the one line of app text
|
|
#if defined(M5STACK_UNITC6L)
|
|
display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits);
|
|
if (gBootSplashBoldPass) {
|
|
display->drawXbm(x + (SCREEN_WIDTH - 50) / 2 + 1, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits);
|
|
}
|
|
display->setFont(FONT_MEDIUM);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
// Draw region in upper left
|
|
if (upperMsg) {
|
|
int msgWidth = display->getStringWidth(upperMsg);
|
|
int msgX = x + (SCREEN_WIDTH - msgWidth) / 2;
|
|
int msgY = y;
|
|
display->drawString(msgX, msgY, upperMsg);
|
|
if (gBootSplashBoldPass) {
|
|
display->drawString(msgX + 1, msgY, upperMsg);
|
|
}
|
|
}
|
|
// Draw version and short name in bottom middle
|
|
char footer[64];
|
|
if (owner.short_name && owner.short_name[0]) {
|
|
snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name);
|
|
} else {
|
|
snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT));
|
|
}
|
|
int footerW = UIRenderer::measureStringWithEmotes(display, footer);
|
|
int footerX = x + ((SCREEN_WIDTH - footerW) / 2);
|
|
UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1,
|
|
false);
|
|
if (gBootSplashBoldPass) {
|
|
UIRenderer::drawStringWithEmotes(display, footerX + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL,
|
|
1, false);
|
|
}
|
|
screen->forceDisplay();
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
|
#else
|
|
display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2, y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + 2,
|
|
icon_width, icon_height, icon_bits);
|
|
|
|
display->setFont(FONT_MEDIUM);
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
const char *title = "meshtastic.org";
|
|
display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title);
|
|
if (gBootSplashBoldPass) {
|
|
display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title);
|
|
}
|
|
display->setFont(FONT_SMALL);
|
|
// Draw region in upper left
|
|
if (upperMsg) {
|
|
display->drawString(x + 5, y + 5, upperMsg);
|
|
if (gBootSplashBoldPass) {
|
|
display->drawString(x + 6, y + 5, upperMsg);
|
|
}
|
|
}
|
|
|
|
// Draw version and short name in upper right
|
|
const char *version = xstr(APP_VERSION_SHORT);
|
|
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version) - 5;
|
|
display->drawString(versionX, y + 5, version);
|
|
if (gBootSplashBoldPass) {
|
|
display->drawString(versionX + 1, y + 5, version);
|
|
}
|
|
if (owner.short_name && owner.short_name[0]) {
|
|
const char *shortName = owner.short_name;
|
|
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
|
|
int shortNameX = x + SCREEN_WIDTH - shortNameW - 5;
|
|
UIRenderer::drawStringWithEmotes(display, shortNameX, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
|
|
if (gBootSplashBoldPass) {
|
|
UIRenderer::drawStringWithEmotes(display, shortNameX + 1, y + 5 + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1,
|
|
false);
|
|
}
|
|
}
|
|
screen->forceDisplay();
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
|
#endif
|
|
}
|
|
|
|
void UIRenderer::drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
// Meshtastic brand green background with black foreground text/icon on TFT startup screen.
|
|
static constexpr uint16_t kMeshtasticGreen = TFTPalette::rgb565(103, 234, 145);
|
|
setAndRegisterTFTColorRole(TFTColorRole::BootSplash, TFTPalette::Black, kMeshtasticGreen, x, y, SCREEN_WIDTH, SCREEN_HEIGHT);
|
|
gBootSplashBoldPass = true;
|
|
#endif
|
|
drawIconScreen(upperMsg, display, state, x, y);
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
gBootSplashBoldPass = false;
|
|
#endif
|
|
}
|
|
|
|
// ****************************
|
|
// * My Position Screen *
|
|
// ****************************
|
|
void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
display->clear();
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
display->setFont(FONT_SMALL);
|
|
int line = 1;
|
|
|
|
// === Set Title
|
|
const char *titleStr = "Position";
|
|
|
|
// === Header ===
|
|
graphics::drawCommonHeader(display, x, y, titleStr);
|
|
const int *textPos = getTextPositions(display);
|
|
|
|
// === First Row: My Location ===
|
|
#if HAS_GPS
|
|
bool origBold = config.display.heading_bold;
|
|
config.display.heading_bold = false;
|
|
|
|
const char *displayLine = ""; // Initialize to empty string by default
|
|
|
|
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
|
|
if (config.position.fixed_position) {
|
|
displayLine = "Fixed GPS";
|
|
} else {
|
|
displayLine = config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT ? "No GPS" : "GPS off";
|
|
}
|
|
drawSatelliteIcon(display, x, textPos[line]);
|
|
int xOffset = (currentResolution == ScreenResolution::High) ? 6 : 0;
|
|
display->drawString(x + 11 + xOffset, textPos[line++], displayLine);
|
|
} else {
|
|
// Onboard GPS
|
|
UIRenderer::drawGps(display, 0, textPos[line++], gpsStatus);
|
|
}
|
|
|
|
config.display.heading_bold = origBold;
|
|
|
|
// === Update GeoCoord ===
|
|
geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
|
|
int32_t(gpsStatus->getAltitude()));
|
|
|
|
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
|
|
const bool hasOwnPositionFix = (ourNode && nodeDB->hasValidPosition(ourNode));
|
|
const bool hasLiveGpsFix =
|
|
(gpsStatus && gpsStatus->getHasLock() && (gpsStatus->getLatitude() != 0 || gpsStatus->getLongitude() != 0));
|
|
const bool hasSensorHeading = screen->hasHeading();
|
|
float heading = 0.0f;
|
|
bool validHeading = false;
|
|
const char *statusLine1 = nullptr;
|
|
const char *statusLine2 = nullptr;
|
|
if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) {
|
|
double headingLat = 0.0;
|
|
double headingLon = 0.0;
|
|
if (hasLiveGpsFix) {
|
|
headingLat = DegD(gpsStatus->getLatitude());
|
|
headingLon = DegD(gpsStatus->getLongitude());
|
|
} else if (hasOwnPositionFix) {
|
|
const auto &op = ourNode->position;
|
|
headingLat = DegD(op.latitude_i);
|
|
headingLon = DegD(op.longitude_i);
|
|
}
|
|
validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading);
|
|
}
|
|
|
|
if (!validHeading) {
|
|
if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) {
|
|
statusLine1 = "No";
|
|
statusLine2 = "Heading";
|
|
} else {
|
|
statusLine1 = "No";
|
|
statusLine2 = "Fix";
|
|
}
|
|
}
|
|
|
|
// If GPS is off, no need to display these parts
|
|
if (strcmp(displayLine, "GPS off") != 0 && strcmp(displayLine, "No GPS") != 0) {
|
|
// === Second Row: Last GPS Fix ===
|
|
if (gpsStatus->getLastFixMillis() > 0) {
|
|
uint32_t delta = millis() - gpsStatus->getLastFixMillis();
|
|
char uptimeStr[32];
|
|
#if defined(USE_EINK)
|
|
// E-Ink: skip seconds, show only days/hours/mins
|
|
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), false);
|
|
#else
|
|
// Non E-Ink: include seconds where useful
|
|
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true);
|
|
#endif
|
|
|
|
display->drawString(0, textPos[line++], uptimeStr);
|
|
} else {
|
|
display->drawString(0, textPos[line++], "Last: ?");
|
|
}
|
|
|
|
// === Third Row: Line 1 GPS Info ===
|
|
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line1");
|
|
|
|
if (uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_OLC &&
|
|
uiconfig.gps_format != meshtastic_DeviceUIConfig_GpsCoordinateFormat_MLS) {
|
|
// === Fourth Row: Line 2 GPS Info ===
|
|
UIRenderer::drawGpsCoordinates(display, x, textPos[line++], gpsStatus, "line2");
|
|
}
|
|
|
|
// === Final Row: Altitude ===
|
|
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);
|
|
}
|
|
#if !defined(M5STACK_UNITC6L)
|
|
// === Draw Compass ===
|
|
if (validHeading || statusLine1) {
|
|
// --- Compass Rendering: landscape (wide) screens use original side-aligned logic ---
|
|
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
|
|
const int16_t topY = textPos[1];
|
|
const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1); // nav row height
|
|
const int16_t usableHeight = bottomY - topY - 5;
|
|
|
|
int16_t compassRadius = usableHeight / 2;
|
|
if (compassRadius < 8)
|
|
compassRadius = 8;
|
|
const int16_t compassX = x + SCREEN_WIDTH - compassRadius - 8;
|
|
|
|
// Center vertically and nudge down slightly to keep "N" clear of header
|
|
const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
|
|
|
|
drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1,
|
|
statusLine2);
|
|
} else {
|
|
// Portrait or square: put compass at the bottom and centered, scaled to fit available space
|
|
// For E-Ink screens, account for navigation bar at the bottom!
|
|
const int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2;
|
|
#if defined(USE_EINK)
|
|
const int margin = 4;
|
|
int availableHeight = SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink
|
|
#else
|
|
const int margin = 4;
|
|
int availableHeight = SCREEN_HEIGHT - yBelowContent - margin;
|
|
#endif
|
|
|
|
if (availableHeight < FONT_HEIGHT_SMALL * 2)
|
|
return;
|
|
|
|
int compassRadius = availableHeight / 2;
|
|
if (compassRadius < 8)
|
|
compassRadius = 8;
|
|
if (compassRadius * 2 > SCREEN_WIDTH - 16)
|
|
compassRadius = (SCREEN_WIDTH - 16) / 2;
|
|
|
|
int compassX = x + SCREEN_WIDTH / 2;
|
|
int compassY = yBelowContent + availableHeight / 2;
|
|
|
|
drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1,
|
|
statusLine2);
|
|
}
|
|
}
|
|
#endif
|
|
#endif // HAS_GPS
|
|
graphics::drawCommonFooter(display, x, y);
|
|
}
|
|
|
|
#ifdef USERPREFS_OEM_TEXT
|
|
|
|
void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
static const uint8_t xbm[] = USERPREFS_OEM_IMAGE_DATA;
|
|
if (currentResolution == ScreenResolution::High) {
|
|
display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2,
|
|
y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH,
|
|
USERPREFS_OEM_IMAGE_HEIGHT, xbm);
|
|
} else {
|
|
|
|
display->drawXbm(x + (SCREEN_WIDTH - USERPREFS_OEM_IMAGE_WIDTH) / 2,
|
|
y + (SCREEN_HEIGHT - USERPREFS_OEM_IMAGE_HEIGHT) / 2 + 2, USERPREFS_OEM_IMAGE_WIDTH,
|
|
USERPREFS_OEM_IMAGE_HEIGHT, xbm);
|
|
}
|
|
|
|
switch (USERPREFS_OEM_FONT_SIZE) {
|
|
case 0:
|
|
display->setFont(FONT_SMALL);
|
|
break;
|
|
case 2:
|
|
display->setFont(FONT_LARGE);
|
|
break;
|
|
default:
|
|
display->setFont(FONT_MEDIUM);
|
|
break;
|
|
}
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
|
const char *title = USERPREFS_OEM_TEXT;
|
|
if (currentResolution == ScreenResolution::High) {
|
|
display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title);
|
|
}
|
|
display->setFont(FONT_SMALL);
|
|
|
|
// Draw region in upper left
|
|
if (upperMsg)
|
|
display->drawString(x + 0, y + 0, upperMsg);
|
|
|
|
// Draw version and shortname in upper right
|
|
const char *version = xstr(APP_VERSION_SHORT);
|
|
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
|
|
display->drawString(versionX, y + 0, version);
|
|
if (owner.short_name && owner.short_name[0]) {
|
|
const char *shortName = owner.short_name;
|
|
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
|
|
int shortNameX = x + SCREEN_WIDTH - shortNameW;
|
|
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
|
|
}
|
|
screen->forceDisplay();
|
|
|
|
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
|
|
}
|
|
|
|
void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
|
{
|
|
// Draw region in upper left
|
|
const char *region = myRegion ? myRegion->name : NULL;
|
|
drawOEMIconScreen(region, display, state, x, y);
|
|
}
|
|
|
|
#endif
|
|
|
|
// Navigation bar overlay implementation
|
|
static int16_t lastFrameIndex = -1;
|
|
static uint32_t lastFrameChangeTime = 0;
|
|
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
|
|
|
|
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
|
|
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
|
|
{
|
|
uint8_t frameToHighlight = state->currentFrame;
|
|
if (state->frameState == IN_TRANSITION && state->transitionFrameTarget < screen->indicatorIcons.size()) {
|
|
frameToHighlight = state->transitionFrameTarget;
|
|
}
|
|
|
|
// Detect frame change and record time
|
|
if (frameToHighlight != lastFrameIndex) {
|
|
lastFrameIndex = frameToHighlight;
|
|
lastFrameChangeTime = millis();
|
|
}
|
|
|
|
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 size_t totalIcons = screen->indicatorIcons.size();
|
|
if (totalIcons == 0)
|
|
return;
|
|
|
|
const int navPadding = (currentResolution == ScreenResolution::High) ? 24 : 12; // padding per side
|
|
|
|
int usableWidth = SCREEN_WIDTH - (navPadding * 2);
|
|
if (usableWidth < iconSize)
|
|
usableWidth = iconSize;
|
|
|
|
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
|
|
const size_t currentPage = frameToHighlight / iconsPerPage;
|
|
const size_t pageStart = currentPage * iconsPerPage;
|
|
const size_t pageEnd = min(pageStart + iconsPerPage, totalIcons);
|
|
|
|
const int totalWidth = (pageEnd - pageStart) * iconSize + (pageEnd - pageStart - 1) * spacing;
|
|
const int xStart = (SCREEN_WIDTH - totalWidth) / 2;
|
|
|
|
const bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS;
|
|
const int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT;
|
|
|
|
#if defined(USE_EINK)
|
|
// Only show bar briefly after switching frames
|
|
static uint32_t navBarLastShown = 0;
|
|
static bool cosmeticRefreshDone = false;
|
|
static bool navBarPrevVisible = false;
|
|
|
|
if (navBarVisible && !navBarPrevVisible) {
|
|
EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when showing nav bar
|
|
cosmeticRefreshDone = false;
|
|
navBarLastShown = millis();
|
|
}
|
|
|
|
if (!navBarVisible && navBarPrevVisible) {
|
|
EINK_ADD_FRAMEFLAG(display, DEMAND_FAST); // Fast refresh when hiding nav bar
|
|
navBarLastShown = millis(); // Mark when it disappeared
|
|
}
|
|
|
|
if (!navBarVisible && navBarLastShown != 0 && !cosmeticRefreshDone) {
|
|
if (millis() - navBarLastShown > 10000) { // 10s after hidden
|
|
EINK_ADD_FRAMEFLAG(display, COSMETIC); // One-time ghost cleanup
|
|
cosmeticRefreshDone = true;
|
|
}
|
|
}
|
|
|
|
navBarPrevVisible = navBarVisible;
|
|
#endif
|
|
|
|
// Pre-calculate bounding rect
|
|
const int rectX = xStart - 2 - bigOffset;
|
|
const int rectY = y - 2;
|
|
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
|
|
const int rectHeight = iconSize + 6;
|
|
|
|
// Clear background and draw border
|
|
display->setColor(BLACK);
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
// NavigationBar and NavigationArrow roles are fully defined in the theme table.
|
|
// We must call setTFTColorRole() before registerTFTColorRegion() because
|
|
// registerTFTColorRegion() snapshots colors from the roleColors[] working array,
|
|
// and loadThemeDefaults() isn't guaranteed to have run since boot.
|
|
const TFTThemeDef &theme = getActiveTheme();
|
|
const auto &navBarRole = theme.roles[static_cast<size_t>(TFTColorRole::NavigationBar)];
|
|
const auto &navArrowRole = theme.roles[static_cast<size_t>(TFTColorRole::NavigationArrow)];
|
|
|
|
setAndRegisterTFTColorRole(TFTColorRole::NavigationBar, navBarRole.onColor, navBarRole.offColor, rectX, rectY, rectWidth,
|
|
rectHeight);
|
|
setTFTColorRole(TFTColorRole::NavigationArrow, navArrowRole.onColor, navArrowRole.offColor);
|
|
display->fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
#else
|
|
// Keep legacy OLED behavior untouched.
|
|
display->fillRect(rectX + 1, rectY, rectWidth - 2, rectHeight - 2);
|
|
display->setColor(WHITE);
|
|
display->drawRect(rectX, rectY, rectWidth, rectHeight);
|
|
#endif
|
|
|
|
// Icons are 1-bit glyphs and must be drawn with WHITE to set pixels.
|
|
display->setColor(WHITE);
|
|
|
|
// Icon drawing loop for the current page
|
|
for (size_t i = pageStart; i < pageEnd; ++i) {
|
|
const uint8_t *icon = screen->indicatorIcons[i];
|
|
const int x = xStart + (i - pageStart) * (iconSize + spacing);
|
|
const bool isActive = (i == static_cast<size_t>(frameToHighlight));
|
|
|
|
if (isActive) {
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
// Active icon inverts on TFT: white chip with black glyph.
|
|
// Keep the buffer visibly different too, so dirty-rect updates include this region.
|
|
registerTFTColorRegion(TFTColorRole::NavigationBar, x - 1, y - 1, iconSize + 2, iconSize + 2);
|
|
display->setColor(WHITE);
|
|
display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2);
|
|
display->setColor(BLACK);
|
|
#else
|
|
display->setColor(WHITE);
|
|
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
|
|
display->setColor(BLACK);
|
|
#endif
|
|
}
|
|
|
|
if (currentResolution == ScreenResolution::High) {
|
|
NodeListRenderer::drawScaledXBitmap16x16(x, y, 8, 8, icon, display);
|
|
} else {
|
|
display->drawXbm(x, y, iconSize, iconSize, icon);
|
|
}
|
|
|
|
if (isActive) {
|
|
display->setColor(WHITE);
|
|
}
|
|
}
|
|
|
|
display->setColor(WHITE);
|
|
|
|
const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1;
|
|
const int halfH = rectHeight / 2;
|
|
const int top = rectY + (rectHeight - halfH) / 2;
|
|
const int bottom = top + halfH - 1;
|
|
const int midY = top + (halfH / 2);
|
|
const int maxW = 4;
|
|
|
|
auto drawArrow = [&](bool rightSide) {
|
|
int baseX = rightSide ? (rectX + rectWidth + offset) : (rectX - offset - 1);
|
|
|
|
for (int yy = top; yy <= bottom; yy++) {
|
|
int dist = abs(yy - midY);
|
|
int lineW = maxW - (dist * maxW / (halfH / 2));
|
|
if (lineW < 1)
|
|
lineW = 1;
|
|
|
|
if (rightSide) {
|
|
display->drawHorizontalLine(baseX, yy, lineW);
|
|
} else {
|
|
display->drawHorizontalLine(baseX - lineW + 1, yy, lineW);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Right arrow
|
|
if (navBarVisible && pageEnd < totalIcons) {
|
|
int baseX = rectX + rectWidth + offset;
|
|
int regionX = baseX;
|
|
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH);
|
|
#endif
|
|
|
|
drawArrow(true);
|
|
}
|
|
|
|
// Left arrow
|
|
if (navBarVisible && pageStart > 0) {
|
|
int baseX = rectX - offset - 1;
|
|
int regionX = baseX - maxW + 1;
|
|
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
registerTFTColorRegion(TFTColorRole::NavigationArrow, regionX, top, maxW, halfH);
|
|
#endif
|
|
|
|
drawArrow(false);
|
|
}
|
|
|
|
// Knock the corners off the square
|
|
#if GRAPHICS_TFT_COLORING_ENABLED
|
|
// TFT corner mask
|
|
registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX, rectY, 1, 1);
|
|
registerTFTColorRegion(TFTColorRole::NavigationArrow, rectX + rectWidth - 1, rectY, 1, 1);
|
|
#else
|
|
// monochrome styling only
|
|
display->setColor(BLACK);
|
|
display->drawRect(rectX, rectY, 1, 1);
|
|
display->drawRect(rectX + rectWidth - 1, rectY, 1, 1);
|
|
display->setColor(WHITE);
|
|
#endif
|
|
}
|
|
|
|
void UIRenderer::drawFrameText(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y, const char *message)
|
|
{
|
|
uint16_t x_offset = display->width() / 2;
|
|
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
|
display->setFont(FONT_MEDIUM);
|
|
display->drawString(x_offset + x, 26 + y, message);
|
|
}
|
|
|
|
std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds)
|
|
{
|
|
std::string uptime;
|
|
|
|
if (days > (HOURS_IN_MONTH * 6))
|
|
uptime = "?";
|
|
else if (days >= 2)
|
|
uptime = std::to_string(days) + "d";
|
|
else if (hours >= 2)
|
|
uptime = std::to_string(hours) + "h";
|
|
else if (minutes >= 1)
|
|
uptime = std::to_string(minutes) + "m";
|
|
else
|
|
uptime = std::to_string(seconds) + "s";
|
|
return uptime;
|
|
}
|
|
|
|
int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing)
|
|
{
|
|
return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing);
|
|
}
|
|
|
|
size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
|
|
const char *ellipsis, int emoteSpacing)
|
|
{
|
|
return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes,
|
|
graphics::numEmotes, emoteSpacing);
|
|
}
|
|
|
|
void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing,
|
|
bool fauxBold)
|
|
{
|
|
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes,
|
|
emoteSpacing, fauxBold);
|
|
}
|
|
|
|
} // namespace graphics
|
|
|
|
#endif // HAS_SCREEN
|