#include "configuration.h"
#if HAS_SCREEN
#include "CompassRenderer.h"
#include "GPSStatus.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "NodeListRenderer.h"
#if !MESHTASTIC_EXCLUDE_STATUS
#include "modules/StatusMessageModule.h"
#endif
#if BASEUI_HAS_GAMES
#include "modules/games/GamesModule.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 <cstring>
#include <gps/RTC.h>

// External variables
extern std::unique_ptr<graphics::Screen> screen;
#if defined(OLED_TINY)
static uint32_t lastSwitchTime = 0;
#endif
namespace graphics
{
NodeNum UIRenderer::currentFavoriteNodeNum = 0;
std::vector<meshtastic_NodeInfoLite *> graphics::UIRenderer::favoritedNodes;
static bool gBootSplashBoldPass = false;

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);
}

#if GRAPHICS_TFT_COLORING_ENABLED
static float getCompassRingAngleOffset(float heading)
{
    return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
}
#endif

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,
                                   bool showRing = true)
{
    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

    if (showRing)
        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 &region = 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,
                                  bool showRing = true)
{
    // 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);
    if (showRing)
        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, bool showRing = true)
{
    // Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
    if (showRing)
        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,
                                        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, showRing);
#else
        drawMonoCompass(display, compassX, compassY, compassRadius, heading, showRing);
#endif
    } else {
        if (showRing)
            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 char *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 ? statusText : "");

    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 (nodeInfoLiteIsFavorite(n))
            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 (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, bool center)
{
    char textString[12];
    if (config.position.fixed_position) {
        // Fixed position overrides live GPS state, regardless of gps_mode
        snprintf(textString, sizeof(textString), "Fixed GPS");
    } else if (config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT) {
        snprintf(textString, sizeof(textString), "No GPS");
    } else if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
        snprintf(textString, sizeof(textString), "GPS off");
    } else if (!gps || !gps->getIsConnected()) {
        snprintf(textString, sizeof(textString), "No Lock");
    } else if (!gps->getHasLock()) {
        snprintf(textString, sizeof(textString), "No Sats");
    } else {
        snprintf(textString, sizeof(textString), "%u sats", gps->getNumSatellites());
    }

    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 + 3, 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)
{
    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, gps->getHasTime() ? "GPS Time Only" : "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, bool center)
{
    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);
    }

    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) {
        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
    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)
{
    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() || !nodeInfoLiteIsFavorite(node))
        return;
    display->clear();
#if defined(OLED_TINY)
    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 = (nodeInfoLiteHasUser(node) && node->short_name[0]) ? node->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);

#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.
    // 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 = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->short_name : nullptr;
    } else {
        username = (nodeInfoLiteHasUser(node) && node->long_name[0]) ? node->long_name : nullptr;
    }

    // Print node's long name (e.g. "Backpack Node")
    if (username) {
        int username_buffer = 0;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
        if (nodeInfoLiteHasXeddsaSigned(node)) {
            if (currentResolution == ScreenResolution::High) {
                graphics::NodeListRenderer::drawScaledXBitmap16x16(x + 2, getTextPositions(display)[line] + 1,
                                                                   xeddsa_shield_width, xeddsa_shield_height, xeddsa_shield,
                                                                   display);
                username_buffer = (xeddsa_shield_width * 2) + 4;
            } else {
                display->drawXbm(x, getTextPositions(display)[line] + 3, xeddsa_shield_width, xeddsa_shield_height,
                                 xeddsa_shield);
                username_buffer = xeddsa_shield_width + 2;
            }
        }
#endif
#if GRAPHICS_TFT_COLORING_ENABLED
        const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username);
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
        if (nodeInfoLiteHasXeddsaSigned(node)) {
            setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black,
                                       x + usernameWidth, getTextPositions(display)[line], username_buffer, FONT_HEIGHT_SMALL);
        }
#endif
        setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black, x,
                                   getTextPositions(display)[line], usernameWidth, FONT_HEIGHT_SMALL);
#endif
        UIRenderer::drawStringWithEmotes(display, x + username_buffer, getTextPositions(display)[line++], username,
                                         FONT_HEIGHT_SMALL, 1, false);
    }

#if !MESHTASTIC_EXCLUDE_STATUS && !MESHTASTIC_EXCLUDE_STATUSDB
    // === Optional: Last received StatusMessage line for this node ===
    // Display it directly under the username line (if we have one). The cache
    // lives on NodeDB now, keyed by NodeNum, so this is an O(1) lookup.
    if (nodeDB) {
        meshtastic_StatusMessage cachedStatus;
        if (nodeDB->copyNodeStatus(node->num, cachedStatus) && cachedStatus.status[0]) {
            drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], cachedStatus.status);
        }
    }
#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 PRESET(LONG_SLOW):
        case PRESET(LONG_MODERATE):
        case PRESET(LONG_FAST):
            return -6.0f;
        case PRESET(MEDIUM_SLOW):
        case PRESET(MEDIUM_FAST):
        case PRESET(MEDIUM_TURBO):
            return -5.5f;
        case PRESET(SHORT_SLOW):
        case PRESET(SHORT_FAST):
        case PRESET(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, imghop);
            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(OLED_TINY)
    // === 4. Uptime (only show if metric is present) ===
    char uptimeStr[32] = "";
    meshtastic_DeviceMetrics nodeMetrics;
    const bool haveNodeMetrics = nodeDB->copyNodeTelemetry(node->num, nodeMetrics);
    if (haveNodeMetrics && nodeMetrics.has_uptime_seconds) {
        char upPrefix[12]; // enough for leftSideSpacing + "Up:"
        snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
        getUptimeStr(nodeMetrics.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) ===
    const meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
    char distStr[24] = ""; // Make buffer big enough for any string
    bool haveDistance = false;

    meshtastic_PositionLite nodePos;
    meshtastic_PositionLite ourPos;
    const bool haveNodePos = nodeDB->copyNodePosition(node->num, nodePos);
    const bool haveOurPos = ourNode && nodeDB->copyNodePosition(ourNode->num, ourPos);
    if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node) && haveNodePos && haveOurPos) {
        // Use shared meter conversion, then format display units with lightweight integer rounding.
        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 > 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 (haveNodeMetrics) {
        bool hasPct = nodeMetrics.has_battery_level;
        bool hasVolt = nodeMetrics.has_voltage && nodeMetrics.voltage > 0.001f;

        int pct = 0;
        float volt = 0.0f;

        if (hasPct) {
            pct = (int)nodeMetrics.battery_level;
        }

        if (hasVolt) {
            volt = nodeMetrics.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 && haveOurPos && haveNodePos) {
        const auto &op = ourPos;
        showCompass = CompassRenderer::getHeadingRadians(DegD(op.latitude_i), DegD(op.longitude_i), myHeading);
        if (showCompass) {
            const auto &p = nodePos;
            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;
    const 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;

    const bool compactPanel = graphics::isCompactPanel(display);

    if (!config.lora.tx_enabled) {
        const char *txdisabled = "Transmit Disabled";
        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) {
            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));
    }
    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, compactPanel);
#endif

#if defined(OLED_TINY)
    line += 1;

    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);

        // === 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];
        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 = (nodeInfoLiteHasUser(ourNode) && ourNode->long_name[0]) ? ourNode->long_name : "";
    const char *shortName = owner.short_name[0] ? 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);
}

#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(OLED_TINY)
    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[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

    int additionalYOffset = 2;
#if defined(BICOLOR_OLED_DISPLAY)
    additionalYOffset += 5;
#endif
    display->drawXbm(x + (SCREEN_WIDTH - icon_width) / 2,
                     y + (SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - icon_height) / 2 + additionalYOffset, icon_width, icon_height,
                     icon_bits);

    display->setFont(FONT_MEDIUM);
    display->setTextAlignment(TEXT_ALIGN_LEFT);
    const char *title = "meshtastic.org";
#if defined(BICOLOR_OLED_DISPLAY)
    additionalYOffset /= 2;
#else
    additionalYOffset = 0;
#endif
    display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5 + additionalYOffset, title);
    if (gBootSplashBoldPass) {
        display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5 + additionalYOffset,
                            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[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       *
// ****************************
// 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();
    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);
    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, compactPanel);

    config.display.heading_bold = origBold;

    // === Update GeoCoord ===
    geoCoord.updateCoords(int32_t(gpsStatus->getLatitude()), int32_t(gpsStatus->getLongitude()),
                          int32_t(gpsStatus->getAltitude()));

    const 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) {
            meshtastic_PositionLite ownPos;
            if (nodeDB->copyNodePosition(ourNode->num, ownPos)) {
                headingLat = DegD(ownPos.latitude_i);
                headingLon = DegD(ownPos.longitude_i);
            }
        }
        validHeading = CompassRenderer::getHeadingRadians(headingLat, headingLon, heading);
    }

    if (!validHeading) {
        if (hasSensorHeading || hasLiveGpsFix || hasOwnPositionFix) {
            statusLine1 = "No";
            statusLine2 = "Heading";
        } else {
            statusLine1 = "No";
            statusLine2 = "Fix";
        }
    }

    // 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 (hasGpsData) {
        // === 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(OLED_TINY)
    // === 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[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;
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)
{
#if BASEUI_HAS_GAMES
    // Hide the navigation bar while a game owns the screen (the attract screen doesn't intercept,
    // so the nav bar stays visible there).
    if (gamesModule && gamesModule->interceptingKeyboardInput())
        return;
#endif

    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 bool compactPanel = graphics::isCompactPanel(display);

    const size_t totalIcons = screen->indicatorIcons.size();
    if (totalIcons == 0)
        return;

        // 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 =
        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);

    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

    if (compactPanel && !navBarVisible)
        return;

    // Pre-calculate bounding rect
    const int rectX = xStart - 2 - bigOffset;
    const int rectY = y - (compactPanel ? 1 : 2);
    const int rectWidth = totalWidth + 4 + (bigOffset * 2);
    const int rectHeight = iconSize + (compactPanel ? 2 : 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
            const int activePadding = compactPanel ? 1 : 2;
            display->setColor(WHITE);
            display->fillRect(x - activePadding, y - activePadding, iconSize + activePadding * 2, iconSize + activePadding * 2);
            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
