diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp
index f315011d8..e8a7f685e 100644
--- a/src/graphics/Screen.cpp
+++ b/src/graphics/Screen.cpp
@@ -39,6 +39,7 @@ along with this program. If not, see .
#include "draw/NodeListRenderer.h"
#include "draw/NotificationRenderer.h"
#include "draw/UIRenderer.h"
+#include "graphics/TFTColorRegions.h"
#include "modules/CannedMessageModule.h"
#if !MESHTASTIC_EXCLUDE_GPS
@@ -54,6 +55,7 @@ along with this program. If not, see .
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTPalette.h"
#include "graphics/emotes.h"
#include "graphics/images.h"
#include "input/TouchScreenImpl1.h"
@@ -69,12 +71,6 @@ along with this program. If not, see .
#include "target_specific.h"
extern MessageStore messageStore;
-#if USE_TFTDISPLAY
-extern uint16_t TFT_MESH;
-#else
-uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
-#endif
-
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
#include "mesh/wifi/WiFiAPClient.h"
#endif
@@ -109,6 +105,27 @@ namespace graphics
// A text message frame + debug frame + all the node infos
FrameCallback *normalFrames;
static uint32_t targetFramerate = IDLE_FRAMERATE;
+#if GRAPHICS_TFT_COLORING_ENABLED
+static inline void prepareFrameColorRegions()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ clearTFTColorRegions();
+ // Full-frame FrameMono inversion for themes that need it (e.g. light themes).
+ if (isThemeFullFrameInvert()) {
+ setAndRegisterTFTColorRole(TFTColorRole::FrameMono, getThemeBodyFg(), getThemeBodyBg(), 0, 0, screen->getWidth(),
+ screen->getHeight());
+ }
+#endif
+}
+#endif
+
+static inline void updateUiFrame(OLEDDisplayUi *ui)
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ prepareFrameColorRegions();
+#endif
+ ui->update();
+}
// Global variables for alert banner - explicitly define with extern "C" linkage to prevent optimization
uint32_t logo_timeout = 5000; // 4 seconds for EACH logo
@@ -227,7 +244,7 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
- ui->update();
+ updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -249,7 +266,7 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
- ui->update();
+ updateUiFrame(ui);
}
// Called to trigger a banner with custom message and duration
@@ -273,7 +290,7 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
- ui->update();
+ updateUiFrame(ui);
}
void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs,
@@ -296,7 +313,7 @@ void Screen::showTextInput(const char *header, const char *initialText, uint32_t
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
ui->setTargetFPS(60);
- ui->update();
+ updateUiFrame(ui);
}
static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
@@ -388,30 +405,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
{
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
- int32_t rawRGB = uiconfig.screen_rgb_color;
-
- // Only validate the combined value once
- if (rawRGB > 0 && rawRGB <= 255255255) {
- LOG_INFO("Setting screen RGB color to user chosen: 0x%06X", rawRGB);
- // Extract each component as a normal int first
- int r = (rawRGB >> 16) & 0xFF;
- int g = (rawRGB >> 8) & 0xFF;
- int b = rawRGB & 0xFF;
- if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
- TFT_MESH = COLOR565(static_cast(r), static_cast(g), static_cast(b));
- }
-#ifdef TFT_MESH_OVERRIDE
- } else if (rawRGB == 0) {
- LOG_INFO("Setting screen RGB color to TFT_MESH_OVERRIDE: 0x%04X", TFT_MESH_OVERRIDE);
- // Default to TFT_MESH_OVERRIDE if available
- TFT_MESH = TFT_MESH_OVERRIDE;
-#endif
- } else {
- // Default best readable yellow color
- LOG_INFO("Setting screen RGB color to default: (255,255,128)");
- TFT_MESH = COLOR565(255, 255, 128);
- }
-
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -474,9 +467,13 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
#endif
#if defined(USE_ST7789)
- static_cast(dispdev)->setRGB(TFT_MESH);
+ // Keep firmware and ST7789 driver region structs layout-compatible:
+ // we pass `graphics::colorRegions` through a type cast below.
+ static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
+ "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
+ static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#elif defined(USE_ST7796)
- static_cast(dispdev)->setRGB(TFT_MESH);
+ static_cast(dispdev)->setRGB(TFTPalette::White);
#endif
ui = new OLEDDisplayUi(dispdev);
@@ -663,16 +660,16 @@ void Screen::setup()
static_cast(dispdev)->setSubtype(7);
#endif
-#if defined(USE_ST7789) && defined(TFT_MESH)
- // Apply custom RGB color (e.g. Heltec T114/T190)
- static_cast(dispdev)->setRGB(TFT_MESH);
+#if defined(USE_ST7789)
+ static_assert(sizeof(graphics::TFTColorRegion) == sizeof(::TFTColorRegion),
+ "graphics::TFTColorRegion layout must match ST7789 TFTColorRegion");
+ static_cast(dispdev)->setRGB(TFTPalette::White, (::TFTColorRegion *)colorRegions);
#endif
#if defined(MUZI_BASE)
dispdev->delayPoweron = true;
#endif
-#if defined(USE_ST7796) && defined(TFT_MESH)
- // Custom text color, if defined in variant.h
- static_cast(dispdev)->setRGB(TFT_MESH);
+#if defined(USE_ST7796)
+ static_cast(dispdev)->setRGB(TFTPalette::White);
#endif
// Initialize display and UI system
@@ -718,7 +715,7 @@ void Screen::setup()
#endif
{
const char *region = myRegion ? myRegion->name : nullptr;
- graphics::UIRenderer::drawIconScreen(region, display, state, x, y);
+ graphics::UIRenderer::drawBootIconScreen(region, display, state, x, y);
}
};
ui->setFrames(alertFrames, 1);
@@ -757,9 +754,9 @@ void Screen::setup()
// Turn on display and trigger first draw
handleSetOn(true);
graphics::currentResolution = graphics::determineScreenResolution(dispdev->height(), dispdev->width());
- ui->update();
+ updateUiFrame(ui);
#ifndef USE_EINK
- ui->update(); // Some SSD1306 clones drop the first draw, so run twice
+ updateUiFrame(ui); // Some SSD1306 clones drop the first draw, so run twice
#endif
serialSinceMsec = millis();
@@ -832,7 +829,7 @@ void Screen::forceDisplay(bool forceUiUpdate)
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(10);
- ui->update();
+ updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
// Return to normal frame rate
@@ -903,9 +900,9 @@ int32_t Screen::runOnce()
static FrameCallback bootOEMFrames[] = {graphics::UIRenderer::drawOEMBootScreen};
static const int bootOEMFrameCount = sizeof(bootOEMFrames) / sizeof(bootOEMFrames[0]);
ui->setFrames(bootOEMFrames, bootOEMFrameCount);
- ui->update();
+ updateUiFrame(ui);
#ifndef USE_EINK
- ui->update();
+ updateUiFrame(ui);
#endif
showingOEMBootScreen = false;
}
@@ -996,7 +993,7 @@ int32_t Screen::runOnce()
// this must be before the frameState == FIXED check, because we always
// want to draw at least one FIXED frame before doing forceDisplay
- ui->update();
+ updateUiFrame(ui);
// Switch to a low framerate (to save CPU) when we are not in transition
// but we should only call setTargetFPS when framestate changes, because
@@ -1058,7 +1055,7 @@ void Screen::setSSLFrames()
// LOG_DEBUG("Show SSL frames");
static FrameCallback sslFrames[] = {NotificationRenderer::drawSSLScreen};
ui->setFrames(sslFrames, 1);
- ui->update();
+ updateUiFrame(ui);
}
}
@@ -1094,7 +1091,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
do {
startUpdate = millis(); // Handle impossibly unlikely corner case of a millis() overflow..
delay(1);
- ui->update();
+ updateUiFrame(ui);
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
@@ -1469,9 +1466,15 @@ void Screen::blink()
dispdev->setBrightness(254);
while (count > 0) {
dispdev->fillRect(0, 0, dispdev->getWidth(), dispdev->getHeight());
+#if GRAPHICS_TFT_COLORING_ENABLED
+ prepareFrameColorRegions();
+#endif
dispdev->display();
delay(50);
dispdev->clear();
+#if GRAPHICS_TFT_COLORING_ENABLED
+ prepareFrameColorRegions();
+#endif
dispdev->display();
delay(50);
count = count - 1;
@@ -1605,6 +1608,9 @@ void Screen::setFastFramerate()
{
#if defined(M5STACK_UNITC6L)
dispdev->clear();
+#if GRAPHICS_TFT_COLORING_ENABLED
+ prepareFrameColorRegions();
+#endif
dispdev->display();
#endif
// We are about to start a transition so speed up fps
@@ -1816,7 +1822,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
- ui->update();
+ updateUiFrame(ui);
return 0;
}
@@ -1831,7 +1837,7 @@ int Screen::handleInputEvent(const InputEvent *event)
static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback};
ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0]));
setFastFramerate(); // Draw ASAP
- ui->update();
+ updateUiFrame(ui);
menuHandler::handleMenuSwitch(dispdev);
return 0;
diff --git a/src/graphics/SharedUIDisplay.cpp b/src/graphics/SharedUIDisplay.cpp
index ec50654ae..becd3e75d 100644
--- a/src/graphics/SharedUIDisplay.cpp
+++ b/src/graphics/SharedUIDisplay.cpp
@@ -1,16 +1,20 @@
#include "configuration.h"
#if HAS_SCREEN
#include "MeshService.h"
+#include "NodeDB.h"
#include "RTC.h"
#include "draw/NodeListRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
#include "graphics/draw/UIRenderer.h"
#include "main.h"
#include "meshtastic/config.pb.h"
#include "modules/ExternalNotificationModule.h"
#include "power.h"
#include
+#include
#include
namespace graphics
@@ -65,6 +69,12 @@ uint32_t lastBlinkShared = 0;
bool isMailIconVisible = true;
uint32_t lastMailBlink = 0;
+static inline bool useClockHeaderAccentTheme(uint32_t themeId)
+{
+ return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen ||
+ themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
+}
+
// *********************************
// * Rounded Header when inverted *
// *********************************
@@ -85,7 +95,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
// *************************
// * Common Header Drawing *
// *************************
-void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date)
+void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date,
+ bool transparent_background, bool use_title_color_override, uint16_t title_color_override)
{
constexpr int HEADER_OFFSET_Y = 1;
y += HEADER_OFFSET_Y;
@@ -100,30 +111,93 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
const int screenW = display->getWidth();
const int screenH = display->getHeight();
+ const int headerHeight = highlightHeight + 2;
+ const uint16_t headerColorForRoles = getThemeHeaderBg();
+ // Color TFT headers use a fixed dark background + white glyphs.
+ // Keep legacy inverted bitmap behavior only for monochrome displays.
+ const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ int statusLeftEndX = 0;
+ int statusRightStartX = screenW;
+ const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0');
+ const auto activeThemeId = getActiveTheme().id;
+ const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId);
+#endif
+
+ {
+ const uint16_t headerColor = getThemeHeaderBg();
+ const uint16_t headerTextColor = getThemeHeaderText();
+ const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor;
+ uint16_t headerStatusColor = getThemeHeaderStatus();
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Clock frame uses transparent header + date + empty title.
+ // For accent clock themes (Pink/Creamsicle + classic monochrome), tint
+ // status items (battery outline, %, date, mail icon) to the header accent.
+ if (useClockHeaderAccent) {
+ headerStatusColor = getThemeHeaderBg();
+ }
+
+ if (transparent_background) {
+ // Transparent clock headers should inherit whatever body off-color is
+ // already active under the header (important for light/inverted themes).
+ const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg());
+ setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, transparentBgColor, transparentBgColor, 0, 0, screenW,
+ headerHeight);
+ setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor);
+ setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor);
+ } else if (useInvertedHeaderStyle) {
+ setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW,
+ headerHeight);
+ setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole);
+ setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor);
+ } else {
+ setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW,
+ headerHeight);
+ setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor);
+ setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor);
+ }
+#endif
- if (!force_no_invert) {
// === Inverted Header Background ===
- if (isInverted) {
+ if (useInvertedHeaderStyle) {
display->setColor(BLACK);
- display->fillRect(0, 0, screenW, highlightHeight + 2);
+ display->fillRect(0, 0, screenW, headerHeight);
display->setColor(WHITE);
drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2);
display->setColor(BLACK);
} else {
display->setColor(BLACK);
- display->fillRect(0, 0, screenW, highlightHeight + 2);
- display->setColor(WHITE);
- if (currentResolution == ScreenResolution::High) {
- display->drawLine(0, 20, screenW, 20);
- } else {
- display->drawLine(0, 14, screenW, 14);
+ display->fillRect(0, 0, screenW, headerHeight);
+// Keep the legacy white separator for monochrome displays only when header background is visible.
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ if (!transparent_background) {
+ display->setColor(WHITE);
+ if (currentResolution == ScreenResolution::High) {
+ display->drawLine(0, 20, screenW, 20);
+ } else {
+ display->drawLine(0, 14, screenW, 14);
+ }
}
+#endif
}
+ if (transparent_background) {
+ display->setColor(WHITE);
+ }
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // TFT role coloring expects foreground glyph bits to be "set".
+ display->setColor(WHITE);
+#endif
+
// === Screen Title ===
const char *headerTitle = titleStr ? titleStr : "";
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2);
+ registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL);
+#endif
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
}
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -152,6 +226,17 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH);
const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2;
+ bool hasBatteryFillRegion = false;
+ int16_t batteryFillRegionX = 0;
+ int16_t batteryFillRegionY = 0;
+ int16_t batteryFillRegionW = 0;
+ int16_t batteryFillRegionH = 0;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent);
+ if (useClockHeaderAccent) {
+ batteryFillColor = getThemeHeaderBg();
+ }
+#endif
int batteryX = 1;
int batteryY = HEADER_OFFSET_Y + 1;
@@ -180,6 +265,15 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12);
int fillWidth = 14 * chargePercent / 100;
display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (fillWidth > 0) {
+ hasBatteryFillRegion = true;
+ batteryFillRegionX = batteryX + 1;
+ batteryFillRegionY = batteryY + 1;
+ batteryFillRegionW = fillWidth;
+ batteryFillRegionH = 11;
+ }
+#endif
}
batteryX += 18; // Icon + 2 pixels
} else {
@@ -194,21 +288,41 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int fillHeight = 8 * chargePercent / 100;
int fillY = batteryY - fillHeight;
display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (fillHeight > 0) {
+ hasBatteryFillRegion = true;
+ batteryFillRegionX = batteryX + 1;
+ batteryFillRegionY = fillY + 10;
+ batteryFillRegionW = 5;
+ batteryFillRegionH = fillHeight;
+ }
+#endif
}
batteryX += 9; // Icon + 2 pixels
}
}
+#if GRAPHICS_TFT_COLORING_ENABLED
+ statusLeftEndX = batteryX + 2;
+#endif
if (chargePercent != 101) {
// === Battery % Display ===
char chargeStr[4];
snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent);
int chargeNumWidth = display->getStringWidth(chargeStr);
+ const int percentWidth = display->getStringWidth("%");
+ const int percentX = batteryX + chargeNumWidth - 1;
display->drawString(batteryX, textY, chargeStr);
- display->drawString(batteryX + chargeNumWidth - 1, textY, "%");
+ display->drawString(percentX, textY, "%");
+#if GRAPHICS_TFT_COLORING_ENABLED
+ statusLeftEndX = percentX + percentWidth + 2;
+#endif
if (isBold) {
display->drawString(batteryX + 1, textY, chargeStr);
- display->drawString(batteryX + chargeNumWidth, textY, "%");
+ display->drawString(percentX + 1, textY, "%");
+#if GRAPHICS_TFT_COLORING_ENABLED
+ statusLeftEndX = percentX + percentWidth + 3;
+#endif
}
}
@@ -253,6 +367,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
timeStrWidth = display->getStringWidth(timeStr);
}
timeX = screenW - xOffset - timeStrWidth + 3;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16);
+#endif
// === Show Mail or Mute Icon to the Left of Time ===
int iconRightEdge = timeX - 2;
@@ -278,7 +395,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconW = 16, iconH = 12;
int iconX = iconRightEdge - iconW;
int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1;
- if (isInverted && !force_no_invert) {
+ if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2);
display->setColor(BLACK);
@@ -293,7 +410,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
} else {
int iconX = iconRightEdge - (mail_width - 2);
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
- if (isInverted && !force_no_invert) {
+ if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2);
display->setColor(BLACK);
@@ -309,7 +426,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconX = iconRightEdge - mute_symbol_big_width;
int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2;
- if (isInverted && !force_no_invert) {
+ if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2);
display->setColor(BLACK);
@@ -323,7 +440,7 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
int iconX = iconRightEdge - mute_symbol_width;
int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2;
- if (isInverted && !force_no_invert) {
+ if (useInvertedHeaderStyle) {
display->setColor(WHITE);
display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2);
display->setColor(BLACK);
@@ -351,7 +468,9 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
} else {
// === No Time Available: Mail/Mute Icon Moves to Far Right ===
int iconRightEdge = screenW - xOffset;
-
+#if GRAPHICS_TFT_COLORING_ENABLED
+ statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12);
+#endif
bool showMail = false;
#ifndef USE_EINK
@@ -393,6 +512,16 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
}
}
}
+#endif
+#if GRAPHICS_TFT_COLORING_ENABLED
+ registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight);
+ if (statusRightStartX < screenW) {
+ registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight);
+ }
+ if (hasBatteryFillRegion) {
+ registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH,
+ batteryFillColor, headerColorForRoles);
+ }
#endif
display->setColor(WHITE); // Reset for other UI
}
@@ -430,14 +559,23 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
return;
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
+ const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale);
+ const int footerH = (connection_icon_height * scale) + (2 * scale);
+ const int iconX = 0;
+ const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale);
+ const int iconW = connection_icon_width * scale;
+ const int iconH = connection_icon_height * scale;
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Only tint the link glyph itself on TFT; keep the footer background black.
+ setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH);
+#endif
+
display->setColor(BLACK);
- display->fillRect(0, SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale), (connection_icon_width * scale),
- (connection_icon_height * scale) + (2 * scale));
+ display->fillRect(0, footerY, SCREEN_WIDTH, footerH);
display->setColor(WHITE);
if (currentResolution == ScreenResolution::High) {
const int bytesPerRow = (connection_icon_width + 7) / 8;
- int iconX = 0;
- int iconY = SCREEN_HEIGHT - (connection_icon_height * 2);
for (int yy = 0; yy < connection_icon_height; ++yy) {
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
@@ -451,65 +589,127 @@ void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
}
} else {
- display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
- connection_icon);
+ display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon);
}
}
bool isAllowedPunctuation(char c)
{
- const std::string allowed = ".,!?;:-_()[]{}'\"@#$/\\&+=%~^ ";
- return allowed.find(c) != std::string::npos;
+ switch (c) {
+ case '.':
+ case ',':
+ case '!':
+ case '?':
+ case ';':
+ case ':':
+ case '-':
+ case '_':
+ case '(':
+ case ')':
+ case '[':
+ case ']':
+ case '{':
+ case '}':
+ case '\'':
+ case '"':
+ case '@':
+ case '#':
+ case '$':
+ case '/':
+ case '\\':
+ case '&':
+ case '+':
+ case '=':
+ case '%':
+ case '~':
+ case '^':
+ case ' ':
+ return true;
+ default:
+ return false;
+ }
}
-static void replaceAll(std::string &s, const std::string &from, const std::string &to)
+static inline size_t utf8CodePointLength(unsigned char lead)
{
- if (from.empty())
- return;
- size_t pos = 0;
- while ((pos = s.find(from, pos)) != std::string::npos) {
- s.replace(pos, from.size(), to);
- pos += to.size();
+ if ((lead & 0x80) == 0x00) {
+ return 1;
}
+ if ((lead & 0xE0) == 0xC0) {
+ return 2;
+ }
+ if ((lead & 0xF0) == 0xE0) {
+ return 3;
+ }
+ if ((lead & 0xF8) == 0xF0) {
+ return 4;
+ }
+ return 1;
}
std::string sanitizeString(const std::string &input)
{
+ static constexpr char kReplacementChar = static_cast(0xBF); // Inverted question mark in ISO-8859-1.
std::string output;
+ output.reserve(input.size());
bool inReplacement = false;
-
- // Make a mutable copy so we can normalize UTF-8 “smart punctuation” into ASCII first.
- std::string s = input;
-
- // Curly single quotes: ‘ ’
- replaceAll(s, "\xE2\x80\x98", "'"); // U+2018
- replaceAll(s, "\xE2\x80\x99", "'"); // U+2019
-
- // Curly double quotes: “ ”
- replaceAll(s, "\xE2\x80\x9C", "\""); // U+201C
- replaceAll(s, "\xE2\x80\x9D", "\""); // U+201D
-
- // En dash / Em dash: – —
- replaceAll(s, "\xE2\x80\x93", "-"); // U+2013
- replaceAll(s, "\xE2\x80\x94", "-"); // U+2014
-
- // Non-breaking space
- replaceAll(s, "\xC2\xA0", " "); // U+00A0
-
- // Now do your original sanitize pass over the normalized string.
- for (unsigned char uc : s) {
- char c = static_cast(uc);
- if (std::isalnum(uc) || isAllowedPunctuation(c)) {
- output += c;
- inReplacement = false;
- } else {
+ const size_t inputSize = input.size();
+ size_t i = 0;
+ while (i < inputSize) {
+ const unsigned char byte0 = static_cast(input[i]);
+ char normalized = '\0';
+ size_t consumed = 0;
+ if (byte0 < 0x80) {
+ normalized = static_cast(byte0);
+ consumed = 1;
+ } else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast(input[i + 1]) == 0x80) {
+ // Smart punctuation: ' ' \" \" - -
+ switch (static_cast(input[i + 2])) {
+ case 0x98:
+ case 0x99:
+ normalized = '\'';
+ consumed = 3;
+ break;
+ case 0x9C:
+ case 0x9D:
+ normalized = '\"';
+ consumed = 3;
+ break;
+ case 0x93:
+ case 0x94:
+ normalized = '-';
+ consumed = 3;
+ break;
+ default:
+ break;
+ }
+ } else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast(input[i + 1]) == 0xA0) {
+ // Non-breaking space.
+ normalized = ' ';
+ consumed = 2;
+ }
+ if (consumed == 0) {
+ size_t seqLen = utf8CodePointLength(byte0);
+ if (seqLen > (inputSize - i)) {
+ seqLen = 1;
+ }
if (!inReplacement) {
- output += static_cast(0xBF); // ISO-8859-1 for inverted question mark
+ output.push_back(kReplacementChar);
inReplacement = true;
}
+ i += seqLen;
+ continue;
}
+ const unsigned char normalizedUc = static_cast(normalized);
+ if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) {
+ output.push_back(normalized);
+ inReplacement = false;
+ } else if (!inReplacement) {
+ output.push_back(kReplacementChar);
+ inReplacement = true;
+ }
+ i += consumed;
}
-
return output;
}
diff --git a/src/graphics/SharedUIDisplay.h b/src/graphics/SharedUIDisplay.h
index 35e767056..95244d099 100644
--- a/src/graphics/SharedUIDisplay.h
+++ b/src/graphics/SharedUIDisplay.h
@@ -1,6 +1,7 @@
#pragma once
#include
+#include
#include
namespace graphics
@@ -52,7 +53,8 @@ void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w,
// Shared battery/time/mail header
void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr = "", bool force_no_invert = false,
- bool show_date = false);
+ bool show_date = false, bool transparent_background = false, bool use_title_color_override = false,
+ uint16_t title_color_override = 0);
// Shared battery/time/mail header
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y);
diff --git a/src/graphics/TFTColorRegions.cpp b/src/graphics/TFTColorRegions.cpp
new file mode 100644
index 000000000..877835ea6
--- /dev/null
+++ b/src/graphics/TFTColorRegions.cpp
@@ -0,0 +1,819 @@
+#include "TFTColorRegions.h"
+#include "NodeDB.h"
+#include "TFTPalette.h"
+
+#include
+
+namespace graphics
+{
+TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
+
+namespace
+{
+
+struct TFTRoleColorsBe {
+ uint16_t onColorBe;
+ uint16_t offColorBe;
+};
+
+static uint8_t colorRegionCount = 0;
+static constexpr uint32_t kFnv1aOffsetBasis = 2166136261u;
+static constexpr uint32_t kFnv1aPrime = 16777619u;
+
+static constexpr uint16_t toBe565(uint16_t color)
+{
+ return static_cast((color >> 8) | (color << 8));
+}
+
+static constexpr bool kRoleIsBody[static_cast(TFTColorRole::Count)] = {
+ false, // HeaderBackground
+ false, // HeaderTitle
+ false, // HeaderStatus
+ true, // SignalBars
+ true, // ConnectionIcon
+ true, // UtilizationFill
+ true, // FavoriteNode
+ true, // ActionMenuBorder
+ true, // ActionMenuBody
+ true, // ActionMenuTitle
+ true, // FrameMono
+ false, // BootSplash
+ true, // FavoriteNodeBGHighlight
+ false, // NavigationBar
+ false // NavigationArrow
+};
+
+static inline bool isBodyColorRole(TFTColorRole role)
+{
+ return kRoleIsBody[static_cast(role)];
+}
+
+static inline bool isMonochromeTheme(uint32_t themeId)
+{
+ return themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite;
+}
+
+static inline uint16_t getMonochromeAccent(uint32_t themeId)
+{
+ return (themeId == ThemeID::MeshtasticGreen) ? TFTPalette::MeshtasticGreen
+ : (themeId == ThemeID::ClassicRed) ? TFTPalette::ClassicRed
+ : TFTPalette::White;
+}
+
+static inline void replaceColor(uint16_t &value, uint16_t from, uint16_t to)
+{
+ if (value == from) {
+ value = to;
+ }
+}
+
+static inline uint32_t fnv1aAppendByte(uint32_t hash, uint8_t value)
+{
+ return (hash ^ value) * kFnv1aPrime;
+}
+
+static inline uint32_t fnv1aAppendU16(uint32_t hash, uint16_t value)
+{
+ hash = fnv1aAppendByte(hash, static_cast(value & 0xFF));
+ hash = fnv1aAppendByte(hash, static_cast((value >> 8) & 0xFF));
+ return hash;
+}
+
+// Compile-time header color overrides (backward-compatible)
+#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
+static constexpr uint16_t kHeaderBackground = TFT_HEADER_BG_COLOR_OVERRIDE;
+#else
+static constexpr uint16_t kHeaderBackground = TFTPalette::DarkGray;
+#endif
+
+#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
+static constexpr uint16_t kTitleColor = TFT_HEADER_TITLE_COLOR_OVERRIDE;
+#else
+static constexpr uint16_t kTitleColor = TFTPalette::White;
+#endif
+
+#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
+static constexpr uint16_t kStatusColor = TFT_HEADER_STATUS_COLOR_OVERRIDE;
+#else
+static constexpr uint16_t kStatusColor = TFTPalette::White;
+#endif
+
+// Theme definitions
+// Stored in kThemes[] and looked up by matching uiconfig.screen_rgb_color
+// against each entry's .uniqueIdentifier field.
+
+static const TFTThemeDef kThemes[] = {
+
+ // Default Dark (ThemeID::DefaultDark = 0)
+ {
+ ThemeID::DefaultDark, // id
+ "Default Dark", // name
+ 0, // uniqueIdentifier
+ // roles[TFTColorRole::Count]
+ {
+ {kHeaderBackground, TFTPalette::Black}, // HeaderBackground
+ {kHeaderBackground, kTitleColor}, // HeaderTitle
+ {kHeaderBackground, kStatusColor}, // HeaderStatus
+ {TFTPalette::Good, TFTPalette::Black}, // SignalBars
+ {TFTPalette::Blue, TFTPalette::Black}, // ConnectionIcon
+ {TFTPalette::Good, TFTPalette::Black}, // UtilizationFill
+ {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNode
+ {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuBorder
+ {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
+ {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::Black}, // BootSplash
+ {TFTPalette::Yellow, TFTPalette::Black}, // FavoriteNodeBGHighlight
+ {kStatusColor, kHeaderBackground}, // NavigationBar (icon fg, bar bg)
+ {kTitleColor, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::Good, // batteryFillGood
+ TFTPalette::Medium, // batteryFillMedium
+ TFTPalette::Bad, // batteryFillBad
+ false, // fullFrameInvert
+ true, // visible
+ },
+
+ // Default Light (ThemeID::DefaultLight = 1)
+ {
+ ThemeID::DefaultLight, // id
+ "Default Light", // name
+ 1, // uniqueIdentifier
+ {
+ {TFTPalette::LightGray, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::LightGray, TFTPalette::Black}, // HeaderTitle
+ {TFTPalette::LightGray, TFTPalette::Black}, // HeaderStatus
+ {TFTPalette::Good, TFTPalette::White}, // SignalBars
+ {TFTPalette::Blue, TFTPalette::White}, // ConnectionIcon
+ {TFTPalette::Good, TFTPalette::White}, // UtilizationFill
+ {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNode
+ {TFTPalette::DarkGray, TFTPalette::White}, // ActionMenuBorder
+ {TFTPalette::Black, TFTPalette::White}, // ActionMenuBody
+ {TFTPalette::DarkGray, TFTPalette::Black}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::Black}, // BootSplash
+ {TFTPalette::Black, TFTPalette::Yellow}, // FavoriteNodeBGHighlight
+ {TFTPalette::Black, TFTPalette::LightGray}, // NavigationBar (icon fg, bar bg)
+ {TFTPalette::Black, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::Good, // batteryFillGood
+ TFTPalette::Medium, // batteryFillMedium
+ TFTPalette::Bad, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Christmas (ThemeID::Christmas = 2)
+ {
+ ThemeID::Christmas, // id
+ "Christmas", // name
+ 2, // uniqueIdentifier
+ {
+ {TFTPalette::ChristmasRed, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderTitle
+ {TFTPalette::ChristmasRed, TFTPalette::Gold}, // HeaderStatus
+ {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // SignalBars
+ {TFTPalette::Gold, TFTPalette::Pine}, // ConnectionIcon
+ {TFTPalette::ChristmasGreen, TFTPalette::Pine}, // UtilizationFill
+ {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNode
+ {TFTPalette::ChristmasRed, TFTPalette::Pine}, // ActionMenuBorder
+ {TFTPalette::White, TFTPalette::Pine}, // ActionMenuBody
+ {TFTPalette::ChristmasRed, TFTPalette::White}, // ActionMenuTitle
+ {TFTPalette::Pine, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::ChristmasRed}, // BootSplash
+ {TFTPalette::Gold, TFTPalette::Pine}, // FavoriteNodeBGHighlight
+ {TFTPalette::Gold, TFTPalette::ChristmasRed}, // NavigationBar (icon fg, bar bg)
+ {TFTPalette::Gold, TFTPalette::Pine}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::ChristmasGreen, // batteryFillGood
+ TFTPalette::Gold, // batteryFillMedium
+ TFTPalette::ChristmasRed, // batteryFillBad
+ true, // fullFrameInvert
+ false, // visible
+ },
+
+ // Pink (ThemeID::Pink = 3) light variant
+ {
+ ThemeID::Pink, // id
+ "Pink", // name
+ 3, // uniqueIdentifier
+ {
+ {TFTPalette::HotPink, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::HotPink, TFTPalette::White}, // HeaderTitle
+ {TFTPalette::HotPink, TFTPalette::White}, // HeaderStatus
+ {TFTPalette::DeepPink, TFTPalette::PalePink}, // SignalBars
+ {TFTPalette::HotPink, TFTPalette::PalePink}, // ConnectionIcon
+ {TFTPalette::DeepPink, TFTPalette::PalePink}, // UtilizationFill
+ {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNode
+ {TFTPalette::HotPink, TFTPalette::PalePink}, // ActionMenuBorder
+ {TFTPalette::Black, TFTPalette::PalePink}, // ActionMenuBody
+ {TFTPalette::HotPink, TFTPalette::White}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::HotPink}, // BootSplash
+ {TFTPalette::Black, TFTPalette::HotPink}, // FavoriteNodeBGHighlight
+ {TFTPalette::White, TFTPalette::HotPink}, // NavigationBar (icon fg, bar bg)
+ {TFTPalette::HotPink, TFTPalette::PalePink}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::DeepPink, // batteryFillGood
+ TFTPalette::HotPink, // batteryFillMedium
+ TFTPalette::Bad, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Blue (ThemeID::Blue = 4) dark variant
+ {
+ ThemeID::Blue, // id
+ "Blue", // name
+ 4, // uniqueIdentifier
+ {
+ {TFTPalette::DeepBlue, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::DeepBlue, TFTPalette::White}, // HeaderTitle
+ {TFTPalette::DeepBlue, TFTPalette::SkyBlue}, // HeaderStatus
+ {TFTPalette::SkyBlue, TFTPalette::Navy}, // SignalBars
+ {TFTPalette::SkyBlue, TFTPalette::Navy}, // ConnectionIcon
+ {TFTPalette::SkyBlue, TFTPalette::Navy}, // UtilizationFill
+ {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNode
+ {TFTPalette::DeepBlue, TFTPalette::Navy}, // ActionMenuBorder
+ {TFTPalette::White, TFTPalette::Navy}, // ActionMenuBody
+ {TFTPalette::DeepBlue, TFTPalette::White}, // ActionMenuTitle
+ {TFTPalette::Navy, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::DeepBlue}, // BootSplash
+ {TFTPalette::SkyBlue, TFTPalette::Navy}, // FavoriteNodeBGHighlight
+ {TFTPalette::SkyBlue, TFTPalette::DeepBlue}, // NavigationBar (icon fg, bar bg)
+ {TFTPalette::SkyBlue, TFTPalette::Black}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::SkyBlue, // batteryFillGood
+ TFTPalette::Medium, // batteryFillMedium
+ TFTPalette::Bad, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Creamsicle (ThemeID::Creamsicle = 5)light variant
+ {
+ ThemeID::Creamsicle, // id
+ "Creamsicle", // name
+ 5, // uniqueIdentifier
+ {
+ {TFTPalette::CreamOrange, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderTitle
+ {TFTPalette::CreamOrange, TFTPalette::White}, // HeaderStatus
+ {TFTPalette::DeepOrange, TFTPalette::Cream}, // SignalBars
+ {TFTPalette::CreamOrange, TFTPalette::Cream}, // ConnectionIcon
+ {TFTPalette::DeepOrange, TFTPalette::Cream}, // UtilizationFill
+ {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNode
+ {TFTPalette::CreamOrange, TFTPalette::Cream}, // ActionMenuBorder
+ {TFTPalette::Black, TFTPalette::Cream}, // ActionMenuBody
+ {TFTPalette::CreamOrange, TFTPalette::White}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::White}, // FrameMono
+ {TFTPalette::White, TFTPalette::CreamOrange}, // BootSplash
+ {TFTPalette::Black, TFTPalette::CreamOrange}, // FavoriteNodeBGHighlight
+ {TFTPalette::White, TFTPalette::CreamOrange}, // NavigationBar (icon fg, bar bg)
+ {TFTPalette::CreamOrange, TFTPalette::White}, // NavigationArrow (arrow fg, body bg)
+ },
+ TFTPalette::DeepOrange, // batteryFillGood
+ TFTPalette::Gold, // batteryFillMedium
+ TFTPalette::Bad, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Meshtastic Green (ThemeID::MeshtasticGreen = 6) classic monochrome
+ // Pure single-color-on-black look. Every role maps foreground pixels to
+ // the theme color and background pixels to Black.
+ {
+ ThemeID::MeshtasticGreen, // id
+ "Meshtastic Green", // name
+ 6, // uniqueIdentifier
+ {
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderTitle
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // HeaderStatus
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // SignalBars
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ConnectionIcon
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // UtilizationFill
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNode
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBorder
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuBody
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::MeshtasticGreen}, // FrameMono (bodyBg, bodyFg)
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // BootSplash
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // FavoriteNodeBGHighlight
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationBar
+ {TFTPalette::MeshtasticGreen, TFTPalette::Black}, // NavigationArrow
+ },
+ TFTPalette::Black, // batteryFillGood
+ TFTPalette::Black, // batteryFillMedium
+ TFTPalette::Black, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Classic Red (ThemeID::ClassicRed = 7) classic monochrome
+ {
+ ThemeID::ClassicRed, // id
+ "Classic Red", // name
+ 7, // uniqueIdentifier
+ {
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderTitle
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // HeaderStatus
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // SignalBars
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // ConnectionIcon
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // UtilizationFill
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNode
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBorder
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuBody
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::ClassicRed}, // FrameMono (bodyBg, bodyFg)
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // BootSplash
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // FavoriteNodeBGHighlight
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationBar
+ {TFTPalette::ClassicRed, TFTPalette::Black}, // NavigationArrow
+ },
+ TFTPalette::Black, // batteryFillGood
+ TFTPalette::Black, // batteryFillMedium
+ TFTPalette::Black, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+
+ // Monochrome White (ThemeID::MonochromeWhite = 8) classic monochrome
+ {
+ ThemeID::MonochromeWhite, // id
+ "Monochrome White", // name
+ 8, // uniqueIdentifier
+ {
+ {TFTPalette::White, TFTPalette::Black}, // HeaderBackground
+ {TFTPalette::White, TFTPalette::Black}, // HeaderTitle
+ {TFTPalette::White, TFTPalette::Black}, // HeaderStatus
+ {TFTPalette::White, TFTPalette::Black}, // SignalBars
+ {TFTPalette::White, TFTPalette::Black}, // ConnectionIcon
+ {TFTPalette::White, TFTPalette::Black}, // UtilizationFill
+ {TFTPalette::White, TFTPalette::Black}, // FavoriteNode
+ {TFTPalette::White, TFTPalette::Black}, // ActionMenuBorder
+ {TFTPalette::White, TFTPalette::Black}, // ActionMenuBody
+ {TFTPalette::White, TFTPalette::Black}, // ActionMenuTitle
+ {TFTPalette::Black, TFTPalette::White}, // FrameMono (bodyBg, bodyFg)
+ {TFTPalette::White, TFTPalette::Black}, // BootSplash
+ {TFTPalette::White, TFTPalette::Black}, // FavoriteNodeBGHighlight
+ {TFTPalette::White, TFTPalette::Black}, // NavigationBar
+ {TFTPalette::White, TFTPalette::Black}, // NavigationArrow
+ },
+ TFTPalette::Black, // batteryFillGood
+ TFTPalette::Black, // batteryFillMedium
+ TFTPalette::Black, // batteryFillBad
+ true, // fullFrameInvert
+ true, // visible
+ },
+};
+
+static constexpr size_t kInternalThemeCount = sizeof(kThemes) / sizeof(kThemes[0]);
+
+// Resolve the kThemes[] index for the currently persisted theme. Called at
+// boot (indirectly via getActiveTheme()) and whenever the active theme is
+// queried, so uiconfig.screen_rgb_color remains the single source of truth.
+// Matches against .uniqueIdentifier - that's the field whose value is stored
+// in the user's config. Falls back to 0 (DefaultDark) if no match is found,
+// which gracefully handles removed or retired themes.
+static inline size_t resolveThemeIndex()
+{
+ const uint32_t savedIdentifier = uiconfig.screen_rgb_color & 0x1F;
+ for (size_t i = 0; i < kInternalThemeCount; i++) {
+ if (kThemes[i].uniqueIdentifier == savedIdentifier)
+ return i;
+ }
+ return 0; // Default Dark fallback
+}
+
+static inline bool normalizeRegion(int16_t &x, int16_t &y, int16_t &width, int16_t &height)
+{
+ if (width <= 0 || height <= 0) {
+ return false;
+ }
+
+ if (x < 0) {
+ width += x;
+ x = 0;
+ }
+ if (y < 0) {
+ height += y;
+ y = 0;
+ }
+
+ return width > 0 && height > 0;
+}
+
+static inline void appendColorRegion(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColorBe, uint16_t offColorBe)
+{
+ // Keep the last slot permanently disabled as a sentinel for ST7789 scans.
+ // This leaves MAX_TFT_COLOR_REGIONS - 1 usable entries.
+ if (colorRegionCount >= MAX_TFT_COLOR_REGIONS - 1) {
+ memmove(&colorRegions[0], &colorRegions[1], sizeof(TFTColorRegion) * (MAX_TFT_COLOR_REGIONS - 2));
+ colorRegionCount = MAX_TFT_COLOR_REGIONS - 2;
+ }
+
+ TFTColorRegion ®ion = colorRegions[colorRegionCount++];
+ region.x = x;
+ region.y = y;
+ region.width = width;
+ region.height = height;
+ region.onColorBe = onColorBe;
+ region.offColorBe = offColorBe;
+ region.enabled = true;
+
+ // Keep one disabled sentinel after the active range for ST7789 countColorRegions().
+ if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
+ colorRegions[colorRegionCount].enabled = false;
+ }
+ colorRegions[MAX_TFT_COLOR_REGIONS - 1].enabled = false;
+}
+
+// Current working role colors (big-endian). Initialised to Dark defaults;
+// call loadThemeDefaults() after boot / theme change to refresh.
+static TFTRoleColorsBe roleColors[static_cast(TFTColorRole::Count)] = {
+ {toBe565(kHeaderBackground), toBe565(TFTPalette::Black)}, // HeaderBackground
+ {toBe565(kHeaderBackground), toBe565(kTitleColor)}, // HeaderTitle
+ {toBe565(kHeaderBackground), toBe565(kStatusColor)}, // HeaderStatus
+ {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // SignalBars
+ {toBe565(TFTPalette::Blue), toBe565(TFTPalette::Black)}, // ConnectionIcon
+ {toBe565(TFTPalette::Good), toBe565(TFTPalette::Black)}, // UtilizationFill
+ {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNode
+ {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::Black)}, // ActionMenuBorder
+ {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // ActionMenuBody
+ {toBe565(TFTPalette::DarkGray), toBe565(TFTPalette::White)}, // ActionMenuTitle
+ {toBe565(TFTPalette::Black), toBe565(TFTPalette::White)}, // FrameMono
+ {toBe565(TFTPalette::White), toBe565(TFTPalette::Black)}, // BootSplash
+ {toBe565(TFTPalette::Yellow), toBe565(TFTPalette::Black)}, // FavoriteNodeBGHighlight
+ {toBe565(kStatusColor), toBe565(kHeaderBackground)}, // NavigationBar
+ {toBe565(kTitleColor), toBe565(TFTPalette::Black)} // NavigationArrow
+};
+
+} // namespace
+
+// Theme accessors
+
+const TFTThemeDef &getActiveTheme()
+{
+ return kThemes[resolveThemeIndex()];
+}
+
+// Visible-theme accessors
+// These iterate only themes flagged .visible = true, preserving kThemes[]
+// order. Menu code should use these so hidden themes don't appear in the
+// picker while still applying correctly if their ID is persisted.
+
+size_t getVisibleThemeCount()
+{
+ size_t count = 0;
+ for (size_t i = 0; i < kInternalThemeCount; i++) {
+ if (kThemes[i].visible)
+ count++;
+ }
+ return count;
+}
+
+const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex)
+{
+ size_t seen = 0;
+ for (size_t i = 0; i < kInternalThemeCount; i++) {
+ if (!kThemes[i].visible)
+ continue;
+ if (seen == visibleIndex)
+ return kThemes[i];
+ seen++;
+ }
+ // Fallback: return first theme (never trust a bad index).
+ return kThemes[0];
+}
+
+size_t getActiveVisibleThemeIndex()
+{
+ const size_t active = resolveThemeIndex();
+ if (!kThemes[active].visible)
+ return SIZE_MAX;
+ size_t visibleIdx = 0;
+ for (size_t i = 0; i < active; i++) {
+ if (kThemes[i].visible)
+ visibleIdx++;
+ }
+ return visibleIdx;
+}
+
+uint16_t getThemeHeaderBg()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+#ifdef TFT_HEADER_BG_COLOR_OVERRIDE
+ return TFT_HEADER_BG_COLOR_OVERRIDE;
+#else
+ return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderBackground)].onColor;
+#endif
+#else
+ return TFTPalette::DarkGray;
+#endif
+}
+
+uint16_t getThemeHeaderText()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+#ifdef TFT_HEADER_TITLE_COLOR_OVERRIDE
+ return TFT_HEADER_TITLE_COLOR_OVERRIDE;
+#else
+ return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderTitle)].offColor;
+#endif
+#else
+ return TFTPalette::White;
+#endif
+}
+
+uint16_t getThemeHeaderStatus()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+#ifdef TFT_HEADER_STATUS_COLOR_OVERRIDE
+ return TFT_HEADER_STATUS_COLOR_OVERRIDE;
+#else
+ return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::HeaderStatus)].offColor;
+#endif
+#else
+ return TFTPalette::White;
+#endif
+}
+
+uint16_t getThemeBodyBg()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].onColor;
+#else
+ return TFTPalette::Black;
+#endif
+}
+
+uint16_t getThemeBodyFg()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ return kThemes[resolveThemeIndex()].roles[static_cast(TFTColorRole::FrameMono)].offColor;
+#else
+ return TFTPalette::White;
+#endif
+}
+
+bool isThemeFullFrameInvert()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ return kThemes[resolveThemeIndex()].fullFrameInvert;
+#else
+ return false;
+#endif
+}
+
+uint16_t getThemeBatteryFillColor(int batteryPercent)
+{
+ const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
+ if (batteryPercent <= 20) {
+ return theme.batteryFillBad;
+ }
+ if (batteryPercent <= 50) {
+ return theme.batteryFillMedium;
+ }
+ return theme.batteryFillGood;
+}
+
+void loadThemeDefaults()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
+ for (uint8_t i = 0; i < static_cast(TFTColorRole::Count); i++) {
+ roleColors[i].onColorBe = toBe565(theme.roles[i].onColor);
+ roleColors[i].offColorBe = toBe565(theme.roles[i].offColor);
+ }
+#endif
+}
+
+// Role color assignment with theme-aware transforms
+
+void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ return;
+#endif
+
+ const uint8_t index = static_cast(role);
+ if (index >= static_cast(TFTColorRole::Count)) {
+ return;
+ }
+
+ const uint32_t themeId = uiconfig.screen_rgb_color & 0x1F;
+ const bool isHighlightRole = (role == TFTColorRole::FavoriteNode || role == TFTColorRole::FavoriteNodeBGHighlight);
+ const bool isBodyRole = !isHighlightRole && isBodyColorRole(role);
+
+ // Classic monochrome themes collapse all non-black accents into one tone.
+ if (isMonochromeTheme(themeId)) {
+ if (onColor != TFTPalette::Black) {
+ onColor = getMonochromeAccent(themeId);
+ }
+ } else {
+ switch (themeId) {
+ case ThemeID::DefaultLight:
+ if (isHighlightRole) {
+ // High-contrast highlight chips on light UI.
+ onColor = TFTPalette::Black;
+ offColor = TFTPalette::Yellow;
+ } else if (isBodyRole) {
+ // Invert body colors for readability on white frames.
+ if (offColor == TFTPalette::Black && role != TFTColorRole::ActionMenuTitle) {
+ offColor = TFTPalette::White;
+ }
+ replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
+ }
+ break;
+ case ThemeID::Christmas:
+ if (isHighlightRole || isBodyRole) {
+ replaceColor(onColor, TFTPalette::Yellow, TFTPalette::Gold);
+ replaceColor(offColor, TFTPalette::Black, TFTPalette::Pine);
+ }
+ break;
+ case ThemeID::Pink:
+ if (isHighlightRole) {
+ onColor = TFTPalette::Black;
+ offColor = TFTPalette::HotPink;
+ } else if (isBodyRole) {
+ replaceColor(offColor, TFTPalette::Black, TFTPalette::PalePink);
+ replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
+ replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepPink);
+ }
+ break;
+ case ThemeID::Creamsicle:
+ if (isHighlightRole) {
+ onColor = TFTPalette::Black;
+ offColor = TFTPalette::CreamOrange;
+ } else if (isBodyRole) {
+ replaceColor(offColor, TFTPalette::Black, TFTPalette::Cream);
+ replaceColor(onColor, TFTPalette::White, TFTPalette::Black);
+ replaceColor(onColor, TFTPalette::Yellow, TFTPalette::DeepOrange);
+ }
+ break;
+ case ThemeID::Blue:
+ if (isHighlightRole || isBodyRole) {
+ replaceColor(onColor, TFTPalette::Yellow, TFTPalette::SkyBlue);
+ replaceColor(offColor, TFTPalette::Black, TFTPalette::Navy);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ roleColors[index].onColorBe = toBe565(onColor);
+ roleColors[index].offColorBe = toBe565(offColor);
+}
+
+// Region registration
+
+void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ return;
+#endif
+
+ const uint8_t roleIndex = static_cast(role);
+ if (roleIndex >= static_cast(TFTColorRole::Count)) {
+ return;
+ }
+
+ if (!normalizeRegion(x, y, width, height)) {
+ return;
+ }
+
+ const TFTRoleColorsBe &colors = roleColors[roleIndex];
+ appendColorRegion(x, y, width, height, colors.onColorBe, colors.offColorBe);
+}
+
+void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
+ int16_t height)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ (void)role;
+ (void)onColor;
+ (void)offColor;
+ (void)x;
+ (void)y;
+ (void)width;
+ (void)height;
+ return;
+#else
+ setTFTColorRole(role, onColor, offColor);
+ registerTFTColorRegion(role, x, y, width, height);
+#endif
+}
+
+void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ return;
+#endif
+
+ if (!normalizeRegion(x, y, width, height))
+ return;
+
+ appendColorRegion(x, y, width, height, toBe565(onColor), toBe565(offColor));
+}
+
+void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ (void)boxLeft;
+ (void)boxTop;
+ (void)boxWidth;
+ (void)boxHeight;
+ return;
+#else
+ // Use theme-appropriate menu colors.
+ const TFTThemeDef &theme = kThemes[resolveThemeIndex()];
+ const TFTThemeRoleColor &menuBody = theme.roles[static_cast(TFTColorRole::ActionMenuBody)];
+ const TFTThemeRoleColor &menuBorder = theme.roles[static_cast(TFTColorRole::ActionMenuBorder)];
+
+ // Fill role includes a 1px shadow guard so stale frame edges are overwritten uniformly.
+ setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBody, menuBody.onColor, menuBody.offColor, boxLeft - 1, boxTop - 1,
+ boxWidth + 2, boxHeight + 2);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop - 2, boxWidth, 1);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft, boxTop + boxHeight + 1, boxWidth, 1);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft - 2, boxTop, 1, boxHeight);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, boxLeft + boxWidth + 1, boxTop, 1, boxHeight);
+
+ setAndRegisterTFTColorRole(TFTColorRole::ActionMenuBorder, menuBorder.onColor, menuBorder.offColor, boxLeft, boxTop, boxWidth,
+ 1);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop + boxHeight - 1, boxWidth, 1);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft, boxTop, 1, boxHeight);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBorder, boxLeft + boxWidth - 1, boxTop, 1, boxHeight);
+#endif
+}
+
+// Frame signature & utilities
+
+uint32_t getTFTColorFrameSignature()
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ return 0;
+#else
+ uint32_t hash = kFnv1aOffsetBasis;
+ hash = fnv1aAppendByte(hash, colorRegionCount);
+ for (uint8_t i = 0; i < colorRegionCount; i++) {
+ const TFTColorRegion &r = colorRegions[i];
+ hash = fnv1aAppendU16(hash, static_cast(r.x));
+ hash = fnv1aAppendU16(hash, static_cast(r.y));
+ hash = fnv1aAppendU16(hash, static_cast(r.width));
+ hash = fnv1aAppendU16(hash, static_cast(r.height));
+ hash = fnv1aAppendU16(hash, r.onColorBe);
+ hash = fnv1aAppendU16(hash, r.offColorBe);
+ }
+
+ return hash;
+#endif
+}
+
+uint8_t getTFTColorRegionCount()
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ return 0;
+#else
+ return colorRegionCount;
+#endif
+}
+
+void clearTFTColorRegions()
+{
+ for (uint8_t i = 0; i < colorRegionCount; i++) {
+ colorRegions[i].enabled = false;
+ }
+ if (colorRegionCount < MAX_TFT_COLOR_REGIONS) {
+ colorRegions[colorRegionCount].enabled = false;
+ }
+ colorRegionCount = 0;
+}
+
+uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor)
+{
+ for (int i = static_cast(colorRegionCount) - 1; i >= 0; i--) {
+ const TFTColorRegion &r = colorRegions[i];
+ if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) {
+ return isset ? r.onColorBe : r.offColorBe;
+ }
+ }
+ return isset ? defaultOnColor : defaultOffColor;
+}
+
+uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor)
+{
+#if !GRAPHICS_TFT_COLORING_ENABLED
+ (void)x;
+ (void)y;
+ return defaultOffColor;
+#else
+ const uint16_t defaultOffBe = toBe565(defaultOffColor);
+ const uint16_t sampledBe = resolveTFTColorPixel(x, y, false, defaultOffBe, defaultOffBe);
+ return static_cast((sampledBe >> 8) | (sampledBe << 8));
+#endif
+}
+
+} // namespace graphics
diff --git a/src/graphics/TFTColorRegions.h b/src/graphics/TFTColorRegions.h
new file mode 100644
index 000000000..fd35bdb1a
--- /dev/null
+++ b/src/graphics/TFTColorRegions.h
@@ -0,0 +1,163 @@
+#pragma once
+
+#include "configuration.h"
+#include
+
+namespace graphics
+{
+
+struct TFTColorRegion {
+ int16_t x;
+ int16_t y;
+ int16_t width;
+ int16_t height;
+ uint16_t onColorBe;
+ uint16_t offColorBe;
+ // Required by ST7789 driver: it scans until the first disabled entry.
+ bool enabled = false;
+};
+
+static constexpr size_t MAX_TFT_COLOR_REGIONS = 48;
+extern TFTColorRegion colorRegions[MAX_TFT_COLOR_REGIONS];
+
+enum class TFTColorRole : uint8_t {
+ HeaderBackground = 0,
+ HeaderTitle,
+ HeaderStatus,
+ SignalBars,
+ ConnectionIcon,
+ UtilizationFill,
+ FavoriteNode,
+ ActionMenuBorder,
+ ActionMenuBody,
+ ActionMenuTitle,
+ FrameMono,
+ BootSplash,
+ FavoriteNodeBGHighlight,
+ NavigationBar,
+ NavigationArrow,
+ Count
+};
+
+#if HAS_TFT || defined(ST7701_CS) || defined(ST7735_CS) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || \
+ defined(ST7789_CS) || defined(HX8357_CS) || defined(USE_ST7789) || defined(ILI9488_CS) || defined(ST7796_CS) || \
+ defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)
+#define GRAPHICS_TFT_COLORING_ENABLED 1
+#else
+#define GRAPHICS_TFT_COLORING_ENABLED 0
+#endif
+
+static constexpr bool kTFTColoringEnabled = GRAPHICS_TFT_COLORING_ENABLED != 0;
+constexpr bool isTFTColoringEnabled()
+{
+ return kTFTColoringEnabled;
+}
+
+void setTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor);
+void registerTFTColorRegion(TFTColorRole role, int16_t x, int16_t y, int16_t width, int16_t height);
+// Convenience helper for the common "set role then register one region" flow.
+void setAndRegisterTFTColorRole(TFTColorRole role, uint16_t onColor, uint16_t offColor, int16_t x, int16_t y, int16_t width,
+ int16_t height);
+// Register a region using explicit colors (no role lookup). Use when the
+// color comes from a theme field rather than a role (e.g. battery fill).
+void registerTFTColorRegionDirect(int16_t x, int16_t y, int16_t width, int16_t height, uint16_t onColor, uint16_t offColor);
+void registerTFTActionMenuRegions(int16_t boxLeft, int16_t boxTop, int16_t boxWidth, int16_t boxHeight);
+uint32_t getTFTColorFrameSignature();
+uint8_t getTFTColorRegionCount();
+void clearTFTColorRegions();
+uint16_t resolveTFTColorPixel(int16_t x, int16_t y, bool isset, uint16_t defaultOnColor, uint16_t defaultOffColor);
+// Resolve effective region-mapped OFF color at a coordinate in native-endian RGB565.
+uint16_t resolveTFTOffColorAt(int16_t x, int16_t y, uint16_t defaultOffColor);
+
+// -- Theme engine ------------------------------------------------------
+// Each theme has four fields that work together:
+//
+// id - ThemeID:: constant, used for in-code references.
+// name - human-readable label shown in the theme picker.
+// uniqueIdentifier - the stable numeric value persisted to
+// uiconfig.screen_rgb_color and restored at boot.
+// This is a CONTRACT with saved configs on disk - once
+// assigned, never reuse or renumber, even if the theme is
+// deleted or the kThemes[] array is reordered.
+// visible - controls whether a theme appears in the picker menu.
+// Hidden themes can still be restored and applied if their
+// uniqueIdentifier is persisted.
+//
+// Display order in the menu is controlled by kThemes[] array order among
+// themes where visible == true, NOT by any numeric value above.
+//
+// To add a new theme:
+// 1. Add a unique constant in ThemeID below (next unused value).
+// 2. Add a kThemes[] entry at the desired menu position, with a unique
+// uniqueIdentifier that has never been used by any prior theme.
+// 3. Set visible=true if it should appear in the picker.
+//
+// To retire a theme without breaking saved configs:
+// - Preferred: keep the entry and set visible=false so existing saved
+// uniqueIdentifier values still resolve to the same theme.
+// - If you remove the entry, resolveThemeIndex() falls back to DefaultDark
+// when the persisted uniqueIdentifier no longer matches any theme.
+// - Do NOT reuse a retired uniqueIdentifier for a future theme.
+namespace ThemeID
+{
+constexpr uint32_t DefaultDark = 0;
+constexpr uint32_t DefaultLight = 1;
+constexpr uint32_t Christmas = 2;
+constexpr uint32_t Pink = 3;
+constexpr uint32_t Blue = 4;
+constexpr uint32_t Creamsicle = 5;
+constexpr uint32_t MeshtasticGreen = 6;
+constexpr uint32_t ClassicRed = 7;
+constexpr uint32_t MonochromeWhite = 8;
+} // namespace ThemeID
+
+// Per-role color pair stored in native (little-endian) RGB565 format.
+struct TFTThemeRoleColor {
+ uint16_t onColor;
+ uint16_t offColor;
+};
+
+// Complete theme definition.
+struct TFTThemeDef {
+ uint32_t id; // ThemeID constant - in-code identifier for this theme.
+ const char *name; // Human-readable label shown in the theme picker.
+ uint32_t uniqueIdentifier; // Stable persisted value copied into uiconfig.screen_rgb_color.
+ // Never reuse or renumber - see file-level notes above.
+ TFTThemeRoleColor roles[static_cast(TFTColorRole::Count)];
+ uint16_t batteryFillGood;
+ uint16_t batteryFillMedium;
+ uint16_t batteryFillBad;
+ bool fullFrameInvert; // Apply full-frame FrameMono inversion (ST7789 light themes)
+ bool visible; // Show in the theme picker menu. Hidden themes still apply
+ // correctly if their uniqueIdentifier is persisted (dev/legacy themes).
+};
+
+// Count of themes whose .visible flag is true. Use this when building menus.
+size_t getVisibleThemeCount();
+
+// Access the Nth visible theme (0 .. getVisibleThemeCount()-1). Hidden themes
+// are skipped, preserving kThemes[] order among the visible entries.
+const TFTThemeDef &getVisibleThemeByIndex(size_t visibleIndex);
+
+// Return the theme that matches uiconfig.screen_rgb_color (falls back to Dark).
+const TFTThemeDef &getActiveTheme();
+
+// Return the visible-theme index for the currently active theme, or SIZE_MAX
+// if the active theme is hidden (so menus can show "no selection").
+size_t getActiveVisibleThemeIndex();
+
+// Convenience accessors - safe to call even when coloring is compiled out.
+uint16_t getThemeHeaderBg();
+uint16_t getThemeHeaderText();
+uint16_t getThemeHeaderStatus();
+uint16_t getThemeBodyBg();
+uint16_t getThemeBodyFg();
+bool isThemeFullFrameInvert();
+uint16_t getThemeBatteryFillColor(int batteryPercent);
+
+// Reinitialise default roleColors from the active theme. Call after a
+// theme change so that any role registered without a prior setTFTColorRole()
+// picks up theme-appropriate defaults.
+void loadThemeDefaults();
+
+} // namespace graphics
diff --git a/src/graphics/TFTDisplay.cpp b/src/graphics/TFTDisplay.cpp
index 4c8272955..7df0c57cc 100644
--- a/src/graphics/TFTDisplay.cpp
+++ b/src/graphics/TFTDisplay.cpp
@@ -16,12 +16,6 @@
extern SX1509 gpioExtender;
#endif
-#ifdef TFT_MESH_OVERRIDE
-uint16_t TFT_MESH = TFT_MESH_OVERRIDE;
-#else
-uint16_t TFT_MESH = COLOR565(0x67, 0xEA, 0x94);
-#endif
-
#if defined(ST7735S)
#include // Graphics and font library for ST7735 driver chip
@@ -1140,7 +1134,9 @@ static LGFX *tft = nullptr;
#endif
#include "SPILock.h"
+#include "TFTColorRegions.h"
#include "TFTDisplay.h"
+#include "TFTPalette.h"
#include
#ifdef UNPHONE
@@ -1150,6 +1146,25 @@ extern unPhone unphone;
GpioPin *TFTDisplay::backlightEnable = NULL;
+namespace
+{
+static constexpr uint8_t kFullRepaintChunkRows = 8;
+
+static inline uint16_t getThemeDefaultOnColor()
+{
+ return graphics::TFTPalette::White;
+}
+
+static inline uint16_t getThemeDefaultOffColor()
+{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ return graphics::getThemeBodyBg();
+#else
+ return TFT_BLACK;
+#endif
+}
+} // namespace
+
TFTDisplay::TFTDisplay(uint8_t address, int sda, int scl, OLEDDISPLAY_GEOMETRY geometry, HW_I2C i2cBus)
{
LOG_DEBUG("TFTDisplay!");
@@ -1189,14 +1204,15 @@ TFTDisplay::~TFTDisplay()
free(linePixelBuffer);
linePixelBuffer = nullptr;
}
+ if (repaintChunkBuffer != nullptr) {
+ free(repaintChunkBuffer);
+ repaintChunkBuffer = nullptr;
+ }
}
// Write the buffer to the display memory
void TFTDisplay::display(bool fromBlank)
{
- if (fromBlank)
- tft->fillScreen(TFT_BLACK);
-
concurrency::LockGuard g(spiLock);
uint32_t x, y;
@@ -1205,12 +1221,70 @@ void TFTDisplay::display(bool fromBlank)
uint32_t x_FirstPixelUpdate;
uint32_t x_LastPixelUpdate;
bool isset, dblbuf_isset;
- uint16_t colorTftMesh, colorTftBlack;
+ uint16_t colorTftWhite, colorTftBlack;
bool somethingChanged = false;
- // Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
- colorTftMesh = __builtin_bswap16(TFT_MESH);
- colorTftBlack = __builtin_bswap16(TFT_BLACK);
+ // Theme defaults for non-role pixels.
+ const uint16_t defaultOnColor = getThemeDefaultOnColor();
+ const uint16_t defaultOffColor = getThemeDefaultOffColor();
+ static uint16_t lastDefaultOnColor = 0;
+ static uint16_t lastDefaultOffColor = 0;
+ static bool haveLastDefaults = false;
+ const bool themeDefaultsChanged =
+ !haveLastDefaults || (defaultOnColor != lastDefaultOnColor) || (defaultOffColor != lastDefaultOffColor);
+ const bool forceFullRepaint = fromBlank || themeDefaultsChanged;
+
+ // If theme defaults changed, reset panel background immediately so stale pixels don't linger.
+ if (forceFullRepaint) {
+ tft->fillScreen(defaultOffColor);
+ }
+
+ colorTftWhite = (defaultOnColor >> 8) | ((defaultOnColor & 0xFF) << 8);
+ colorTftBlack = (defaultOffColor >> 8) | ((defaultOffColor & 0xFF) << 8);
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ static uint32_t lastColorFrameSignature = 0;
+ const bool hasColorRegions = graphics::getTFTColorRegionCount() > 0;
+ const uint32_t colorFrameSignature = graphics::getTFTColorFrameSignature();
+ const bool forceFullColorRepaint = forceFullRepaint || (colorFrameSignature != lastColorFrameSignature);
+
+ // When region roles/layout changed, color can differ even with identical monochrome glyph bits.
+ // Repaint full frame only for those frames, then return to diff-based updates.
+ if (forceFullColorRepaint) {
+ for (uint32_t yStart = 0; yStart < displayHeight; yStart += kFullRepaintChunkRows) {
+ const uint32_t rowsThisChunk = min(kFullRepaintChunkRows, displayHeight - yStart);
+ for (uint32_t row = 0; row < rowsThisChunk; row++) {
+ y = yStart + row;
+ y_byteIndex = (y / 8) * displayWidth;
+ y_byteMask = (1 << (y & 7));
+
+ uint16_t *chunkRow = repaintChunkBuffer + (row * displayWidth);
+ for (x = 0; x < displayWidth; x++) {
+ isset = (buffer[x + y_byteIndex] & y_byteMask) != 0;
+ if (hasColorRegions) {
+ chunkRow[x] = graphics::resolveTFTColorPixel(static_cast(x), static_cast(y), isset,
+ colorTftWhite, colorTftBlack);
+ } else {
+ chunkRow[x] = isset ? colorTftWhite : colorTftBlack;
+ }
+ }
+ }
+#if defined(HACKADAY_COMMUNICATOR)
+ tft->draw16bitBeRGBBitmap(0, yStart, repaintChunkBuffer, displayWidth, rowsThisChunk);
+#else
+ tft->pushImage(0, yStart, displayWidth, rowsThisChunk, repaintChunkBuffer);
+#endif
+ }
+
+ memcpy(buffer_back, buffer, displayBufferSize);
+ lastColorFrameSignature = colorFrameSignature;
+ haveLastDefaults = true;
+ lastDefaultOnColor = defaultOnColor;
+ lastDefaultOffColor = defaultOffColor;
+ graphics::clearTFTColorRegions();
+ return;
+ }
+#endif
y = 0;
while (y < displayHeight) {
@@ -1219,7 +1293,7 @@ void TFTDisplay::display(bool fromBlank)
// Step 1: Do a quick scan of 8 rows together. This allows fast-forwarding over unchanged screen areas.
if (y_byteMask == 1) {
- if (!fromBlank) {
+ if (!forceFullRepaint) {
for (x = 0; x < displayWidth; x++) {
if (buffer[x + y_byteIndex] != buffer_back[x + y_byteIndex])
break;
@@ -1237,13 +1311,14 @@ void TFTDisplay::display(bool fromBlank)
}
}
- // Step 2: Scan each of the 8 rows individually. Find the first pixel in each row that needs updating
- for (x_FirstPixelUpdate = 0; x_FirstPixelUpdate < displayWidth; x_FirstPixelUpdate++) {
- isset = buffer[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
+ // Step 2: Scan this row for changed span (first and last changed pixel).
+ uint32_t x_FirstChanged = 0;
+ for (x_FirstChanged = 0; x_FirstChanged < displayWidth; x_FirstChanged++) {
+ isset = buffer[x_FirstChanged + y_byteIndex] & y_byteMask;
- if (!fromBlank) {
+ if (!forceFullRepaint) {
// get src pixel in the page based ordering the OLED lib uses
- dblbuf_isset = buffer_back[x_FirstPixelUpdate + y_byteIndex] & y_byteMask;
+ dblbuf_isset = buffer_back[x_FirstChanged + y_byteIndex] & y_byteMask;
if (isset != dblbuf_isset) {
break;
}
@@ -1253,43 +1328,51 @@ void TFTDisplay::display(bool fromBlank)
}
// Did we find a pixel that needs updating on this row?
- if (x_FirstPixelUpdate < displayWidth) {
- // Align the first pixel for update to an even number so the total alignment of
- // the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
- x_FirstPixelUpdate &= ~1;
-
- // Step 3a: copy rest of the pixels in this row into the pixel line buffer,
- // while also recording the last pixel in the row that needs updating.
- // Since the first changed pixel will be looked up, the x_LastPixelUpdate will be set.
- for (x = x_FirstPixelUpdate; x < displayWidth; x++) {
- isset = buffer[x + y_byteIndex] & y_byteMask;
- linePixelBuffer[x] = isset ? colorTftMesh : colorTftBlack;
-
- if (!fromBlank) {
- dblbuf_isset = buffer_back[x + y_byteIndex] & y_byteMask;
+ if (x_FirstChanged < displayWidth) {
+ uint32_t x_LastChanged = displayWidth - 1;
+ while (x_LastChanged > x_FirstChanged) {
+ isset = buffer[x_LastChanged + y_byteIndex] & y_byteMask;
+ if (!forceFullRepaint) {
+ dblbuf_isset = buffer_back[x_LastChanged + y_byteIndex] & y_byteMask;
if (isset != dblbuf_isset) {
- x_LastPixelUpdate = x;
+ break;
}
} else if (isset) {
- x_LastPixelUpdate = x;
+ break;
}
+ x_LastChanged--;
}
- // Step 3b: Round up the last pixel to odd number to maintain 32-bit alignment for SPIs.
- // Most displays will have even number of pixels in a row -- this will be in bounds
- // of the displayWidth. (Hopefully odd displays will just ignore that extra pixel.)
- x_LastPixelUpdate |= 1;
- // Ensure the last pixel index does not exceed the display width.
+
+ // Align the first pixel for update to an even number so the total alignment of
+ // the data will be at 32-bit boundary, which is required by GDMA SPI transfers.
+ x_FirstPixelUpdate = x_FirstChanged & ~1U;
+ x_LastPixelUpdate = x_LastChanged | 1U;
if (x_LastPixelUpdate >= displayWidth) {
x_LastPixelUpdate = displayWidth - 1;
}
+
+ // Step 3: Copy only the changed span into the pixel line buffer.
+ for (x = x_FirstPixelUpdate; x <= x_LastPixelUpdate; x++) {
+ isset = buffer[x + y_byteIndex] & y_byteMask;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (hasColorRegions) {
+ linePixelBuffer[x] = graphics::resolveTFTColorPixel(static_cast(x), static_cast(y), isset,
+ colorTftWhite, colorTftBlack);
+ } else {
+ linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
+ }
+#else
+ linePixelBuffer[x] = isset ? colorTftWhite : colorTftBlack;
+#endif
+ }
#if defined(HACKADAY_COMMUNICATOR)
tft->draw16bitBeRGBBitmap(x_FirstPixelUpdate, y, &linePixelBuffer[x_FirstPixelUpdate],
(x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1);
#else
// Step 4: Send the changed pixels on this line to the screen as a single block transfer.
// This function accepts pixel data MSB first so it can dump the memory straight out the SPI port.
- tft->pushRect(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
- &linePixelBuffer[x_FirstPixelUpdate]);
+ tft->pushImage(x_FirstPixelUpdate, y, (x_LastPixelUpdate - x_FirstPixelUpdate + 1), 1,
+ &linePixelBuffer[x_FirstPixelUpdate]);
#endif
somethingChanged = true;
}
@@ -1298,6 +1381,14 @@ void TFTDisplay::display(bool fromBlank)
// Copy the Buffer to the Back Buffer
if (somethingChanged)
memcpy(buffer_back, buffer, displayBufferSize);
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+ lastColorFrameSignature = colorFrameSignature;
+#endif
+ haveLastDefaults = true;
+ lastDefaultOnColor = defaultOnColor;
+ lastDefaultOffColor = defaultOffColor;
+ graphics::clearTFTColorRegions();
}
void TFTDisplay::sdlLoop()
@@ -1511,7 +1602,7 @@ bool TFTDisplay::connect()
#else
tft->setRotation(3); // Orient horizontal and wide underneath the silkscreen name label
#endif
- tft->fillScreen(TFT_BLACK);
+ tft->fillScreen(getThemeDefaultOffColor());
if (this->linePixelBuffer == NULL) {
this->linePixelBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth);
@@ -1521,6 +1612,14 @@ bool TFTDisplay::connect()
return false;
}
}
+ if (this->repaintChunkBuffer == NULL) {
+ this->repaintChunkBuffer = (uint16_t *)malloc(sizeof(uint16_t) * displayWidth * kFullRepaintChunkRows);
+
+ if (!this->repaintChunkBuffer) {
+ LOG_ERROR("Not enough memory to create TFT repaint chunk buffer\n");
+ return false;
+ }
+ }
return true;
}
diff --git a/src/graphics/TFTDisplay.h b/src/graphics/TFTDisplay.h
index a64922d23..2c86f05d2 100644
--- a/src/graphics/TFTDisplay.h
+++ b/src/graphics/TFTDisplay.h
@@ -63,4 +63,5 @@ class TFTDisplay : public OLEDDisplay
virtual bool connect() override;
uint16_t *linePixelBuffer = nullptr;
-};
\ No newline at end of file
+ uint16_t *repaintChunkBuffer = nullptr;
+};
diff --git a/src/graphics/TFTPalette.h b/src/graphics/TFTPalette.h
new file mode 100644
index 000000000..516a9f057
--- /dev/null
+++ b/src/graphics/TFTPalette.h
@@ -0,0 +1,70 @@
+#pragma once
+
+#include
+
+namespace graphics
+{
+namespace TFTPalette
+{
+
+constexpr uint16_t rgb565(uint8_t red, uint8_t green, uint8_t blue)
+{
+ return static_cast(((red & 0xF8) << 8) | ((green & 0xFC) << 3) | ((blue & 0xF8) >> 3));
+}
+
+constexpr uint16_t Black = 0x0000;
+constexpr uint16_t White = 0xFFFF;
+constexpr uint16_t DarkGray = 0x4208;
+constexpr uint16_t Gray = 0x8410;
+constexpr uint16_t LightGray = 0xC618;
+
+constexpr uint16_t Red = rgb565(255, 0, 0);
+constexpr uint16_t Green = rgb565(0, 255, 0);
+constexpr uint16_t Blue = rgb565(0, 130, 252);
+constexpr uint16_t Yellow = rgb565(255, 255, 0);
+constexpr uint16_t Orange = rgb565(255, 165, 0);
+constexpr uint16_t Cyan = rgb565(0, 255, 255);
+constexpr uint16_t Magenta = rgb565(255, 0, 255);
+
+constexpr uint16_t Good = Green;
+constexpr uint16_t Medium = Yellow;
+constexpr uint16_t Bad = Red;
+
+// Christmas / seasonal accent colors
+constexpr uint16_t ChristmasRed = rgb565(178, 34, 34);
+constexpr uint16_t ChristmasGreen = rgb565(0, 128, 0);
+constexpr uint16_t Gold = rgb565(255, 215, 0);
+constexpr uint16_t Pine = rgb565(15, 35, 10);
+
+// Pink theme colors (light variant)
+constexpr uint16_t HotPink = rgb565(255, 105, 180);
+constexpr uint16_t PalePink = rgb565(255, 228, 235);
+constexpr uint16_t DeepPink = rgb565(200, 50, 120);
+
+// Blue theme colors (dark variant)
+constexpr uint16_t SkyBlue = rgb565(100, 180, 255);
+constexpr uint16_t Navy = rgb565(15, 15, 50);
+constexpr uint16_t DeepBlue = rgb565(30, 60, 120);
+
+// Creamsicle theme colors (light variant)
+constexpr uint16_t CreamOrange = rgb565(255, 140, 50);
+constexpr uint16_t DeepOrange = rgb565(220, 100, 20);
+constexpr uint16_t Cream = rgb565(255, 248, 235);
+
+// Classic monochrome theme accent colors (single-color-on-black themes)
+constexpr uint16_t MeshtasticGreen = rgb565(0x67, 0xEA, 0x94);
+constexpr uint16_t ClassicRed = rgb565(255, 64, 64);
+// Monochrome White reuses TFTPalette::White above.
+
+// Fast contrast picker for monochrome glyph overlays on arbitrary RGB565 backgrounds.
+// Uses channel-sum brightness approximation to keep code size small.
+constexpr uint16_t pickReadableMonoFg(uint16_t backgroundColor)
+{
+ const uint16_t r = (backgroundColor >> 11) & 0x1F;
+ const uint16_t g = (backgroundColor >> 5) & 0x3F;
+ const uint16_t b = backgroundColor & 0x1F;
+ return ((r + g + b) >= 70) ? DarkGray : White;
+}
+
+} // namespace TFTPalette
+} // namespace graphics
diff --git a/src/graphics/draw/ClockRenderer.cpp b/src/graphics/draw/ClockRenderer.cpp
index 66bbe1bfe..5045174be 100644
--- a/src/graphics/draw/ClockRenderer.cpp
+++ b/src/graphics/draw/ClockRenderer.cpp
@@ -145,7 +145,7 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// === Set Title, Blank for Clock
const char *titleStr = "";
// === Header ===
- graphics::drawCommonHeader(display, x, y, titleStr, true, true);
+ graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); // Display local timezone
char timeString[16];
@@ -293,11 +293,15 @@ void drawDigitalClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int1
// Draw an analog clock
void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Clear previous frame pixels so moving hands don't leave stale artifacts on TFT light theme.
+ display->clear();
+#endif
display->setTextAlignment(TEXT_ALIGN_LEFT);
// === Set Title, Blank for Clock
const char *titleStr = "";
// === Header ===
- graphics::drawCommonHeader(display, x, y, titleStr, true, true);
+ graphics::drawCommonHeader(display, x, y, titleStr, true, true, true);
// clock face center coordinates
int16_t centerX = display->getWidth() / 2;
@@ -478,4 +482,4 @@ void drawAnalogClockFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
} // namespace ClockRenderer
} // namespace graphics
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/draw/CompassRenderer.cpp b/src/graphics/draw/CompassRenderer.cpp
index fe54d68e7..1ee02194c 100644
--- a/src/graphics/draw/CompassRenderer.cpp
+++ b/src/graphics/draw/CompassRenderer.cpp
@@ -9,113 +9,61 @@ namespace graphics
{
namespace CompassRenderer
{
-
-// Point helper class for compass calculations
-struct Point {
- float x, y;
- Point(float x, float y) : x(x), y(y) {}
-
- void rotate(float angle)
- {
- float cos_a = cosf(angle);
- float sin_a = sinf(angle);
- float new_x = x * cos_a - y * sin_a;
- float new_y = x * sin_a + y * cos_a;
- x = new_x;
- y = new_y;
- }
-
- void scale(float factor)
- {
- x *= factor;
- y *= factor;
- }
-
- void translate(float dx, float dy)
- {
- x += dx;
- y += dy;
- }
-};
-
void drawCompassNorth(OLEDDisplay *display, int16_t compassX, int16_t compassY, float myHeading, int16_t radius)
{
- // Show the compass heading (not implemented in original)
- // This could draw a "N" indicator or north arrow
- // For now, we'll draw a simple north indicator
- // const float radius = 17.0f;
if (currentResolution == ScreenResolution::High) {
radius += 4;
}
- float northX = 0.0f;
- float northY = -radius;
- if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) {
- const float c = cosf(-myHeading);
- const float s = sinf(-myHeading);
- const float rx = northX * c - northY * s;
- const float ry = northX * s + northY * c;
- northX = rx;
- northY = ry;
- }
- northX += compassX;
- northY += compassY;
+
+ const float northAngle = (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -myHeading : 0.0f;
+ const int16_t nX = compassX + static_cast((radius - 1) * sinf(northAngle));
+ const int16_t nY = compassY - static_cast((radius - 1) * cosf(northAngle));
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
+#if !GRAPHICS_TFT_COLORING_ENABLED
display->setColor(BLACK);
const int16_t nLabelWidth = display->getStringWidth("N");
if (currentResolution == ScreenResolution::High) {
- display->fillRect(northX - 8, northY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
+ display->fillRect(nX - 8, nY - 1, nLabelWidth + 3, FONT_HEIGHT_SMALL - 6);
} else {
- display->fillRect(northX - 4, northY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
+ display->fillRect(nX - 4, nY - 1, nLabelWidth + 2, FONT_HEIGHT_SMALL - 6);
}
- display->setColor(WHITE);
- display->drawString(northX, northY - 3, "N");
-}
-
-void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
-{
- Point tip(0.0f, -0.5f), tail(0.0f, 0.35f); // pointing up initially
- float arrowOffsetX = 0.14f, arrowOffsetY = 0.9f;
- Point leftArrow(tip.x - arrowOffsetX, tip.y + arrowOffsetY), rightArrow(tip.x + arrowOffsetX, tip.y + arrowOffsetY);
-
- Point *arrowPoints[] = {&tip, &tail, &leftArrow, &rightArrow};
-
- for (int i = 0; i < 4; i++) {
- arrowPoints[i]->rotate(headingRadian);
- arrowPoints[i]->scale(compassDiam * 0.6);
- arrowPoints[i]->translate(compassX, compassY);
- }
-
-#ifdef USE_EINK
- display->drawTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
-#else
- display->fillTriangle(tip.x, tip.y, rightArrow.x, rightArrow.y, tail.x, tail.y);
#endif
- display->drawTriangle(tip.x, tip.y, leftArrow.x, leftArrow.y, tail.x, tail.y);
+ display->setColor(WHITE);
+ display->drawString(nX, nY - 3, "N");
}
void drawArrowToNode(OLEDDisplay *display, int16_t x, int16_t y, int16_t size, float bearing)
{
- float radians = bearing * DEG_TO_RAD;
+ const float radians = bearing * DEG_TO_RAD;
+ const float sinA = sinf(radians);
+ const float cosA = cosf(radians);
+ const float tipHalf = size * 0.5f;
+ const float lx = -(size / 6.0f);
+ const float ly = size / 4.0f;
+ const float rx = (size / 6.0f);
+ const float ry = size / 4.0f;
+ const float tx = 0.0f;
+ const float ty = size / 4.5f;
- Point tip(0, -size / 2);
- Point left(-size / 6, size / 4);
- Point right(size / 6, size / 4);
- Point tail(0, size / 4.5);
+ const int16_t tipX = static_cast(x + (tipHalf * sinA));
+ const int16_t tipY = static_cast(y - (tipHalf * cosA));
+ const int16_t leftX = static_cast(x + (lx * cosA) - (ly * sinA));
+ const int16_t leftY = static_cast(y + (lx * sinA) + (ly * cosA));
+ const int16_t rightX = static_cast(x + (rx * cosA) - (ry * sinA));
+ const int16_t rightY = static_cast(y + (rx * sinA) + (ry * cosA));
+ const int16_t tailX = static_cast(x + (tx * cosA) - (ty * sinA));
+ const int16_t tailY = static_cast(y + (tx * sinA) + (ty * cosA));
- tip.rotate(radians);
- left.rotate(radians);
- right.rotate(radians);
- tail.rotate(radians);
+ display->fillTriangle(tipX, tipY, leftX, leftY, tailX, tailY);
+ display->fillTriangle(tipX, tipY, rightX, rightY, tailX, tailY);
+}
- tip.translate(x, y);
- left.translate(x, y);
- right.translate(x, y);
- tail.translate(x, y);
-
- display->fillTriangle(tip.x, tip.y, left.x, left.y, tail.x, tail.y);
- display->fillTriangle(tip.x, tip.y, right.x, right.y, tail.x, tail.y);
+void drawNodeHeading(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam, float headingRadian)
+{
+ const int16_t size = static_cast(compassDiam * 0.6f);
+ drawArrowToNode(display, compassX, compassY, size, headingRadian * RAD_TO_DEG);
}
bool getHeadingRadians(double lat, double lon, float &headingRadian)
diff --git a/src/graphics/draw/CompassRenderer.h b/src/graphics/draw/CompassRenderer.h
index d77623847..41adf6e64 100644
--- a/src/graphics/draw/CompassRenderer.h
+++ b/src/graphics/draw/CompassRenderer.h
@@ -1,6 +1,7 @@
#pragma once
#include "graphics/Screen.h"
+#include "mesh/generated/meshtastic/mesh.pb.h"
#include
#include
diff --git a/src/graphics/draw/DebugRenderer.cpp b/src/graphics/draw/DebugRenderer.cpp
index 7a12650ca..67136437a 100644
--- a/src/graphics/draw/DebugRenderer.cpp
+++ b/src/graphics/draw/DebugRenderer.cpp
@@ -11,6 +11,8 @@
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/images.h"
#include "main.h"
@@ -469,9 +471,11 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
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
int chutil_bar_height = (currentResolution == ScreenResolution::High) ? 12 : 7;
int extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 3;
int chutil_percent = airTime->channelUtilizationPercent();
+ const int raw_chutil_percent = chutil_percent;
int centerofscreen = SCREEN_WIDTH / 2;
int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
@@ -479,7 +483,7 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
display->drawString(starting_position, getTextPositions(display)[line], chUtil);
- // Force 56% or higher to show a full 100% bar, text would still show related percent.
+ // Force 61% or higher to show a full 100% bar, text would still show related percent.
if (chutil_percent >= 61) {
chutil_percent = 100;
}
@@ -492,9 +496,9 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
float weight3 = 0.20; // Weight for 40–100%
float totalWeight = weight1 + weight2 + weight3;
- int seg1 = chutil_bar_width * (weight1 / totalWeight);
- int seg2 = chutil_bar_width * (weight2 / totalWeight);
- int seg3 = chutil_bar_width * (weight3 / totalWeight);
+ int seg1 = chutil_bar_max_fill * (weight1 / totalWeight);
+ int seg2 = chutil_bar_max_fill * (weight2 / totalWeight);
+ int seg3 = chutil_bar_max_fill - seg1 - seg2; // Remainder absorbs rounding errors
int fillRight = 0;
@@ -511,7 +515,17 @@ void drawLoRaFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x,
// Fill progress
if (fillRight > 0) {
- display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
+#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++],
@@ -584,6 +598,17 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
display->setColor(WHITE);
display->drawRect(barX, barY, adjustedBarWidth, barHeight);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ uint16_t UtilizationFillColor = TFTPalette::Good;
+ if (percent >= 80) {
+ UtilizationFillColor = TFTPalette::Bad;
+ } else if (percent >= 60) {
+ UtilizationFillColor = TFTPalette::Medium;
+ }
+ setAndRegisterTFTColorRole(TFTColorRole::UtilizationFill, UtilizationFillColor, TFTPalette::Black, barX + 1, barY + 1,
+ fillWidth - 1, barHeight - 2);
+#endif
+
display->fillRect(barX, barY, fillWidth, barHeight);
display->setColor(WHITE);
#endif
diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp
index e92ba4839..f31cb405b 100644
--- a/src/graphics/draw/MenuHandler.cpp
+++ b/src/graphics/draw/MenuHandler.cpp
@@ -11,6 +11,7 @@
#include "buzz.h"
#include "graphics/Screen.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
#include "graphics/draw/MessageRenderer.h"
#include "graphics/draw/UIRenderer.h"
#include "input/RotaryEncoderInterruptImpl1.h"
@@ -30,8 +31,6 @@
#include
#include
-extern uint16_t TFT_MESH;
-
namespace graphics
{
@@ -2028,109 +2027,6 @@ void menuHandler::switchToMUIMenu()
screen->showOverlayBanner(bannerOptions);
}
-void menuHandler::TFTColorPickerMenu(OLEDDisplay *display)
-{
- static const ScreenColorOption colorOptions[] = {
- {"Back", OptionsAction::Back},
- {"Default", OptionsAction::Select, ScreenColor(0, 0, 0, true)},
- {"Meshtastic Green", OptionsAction::Select, ScreenColor(0x67, 0xEA, 0x94)},
- {"Yellow", OptionsAction::Select, ScreenColor(255, 255, 128)},
- {"Red", OptionsAction::Select, ScreenColor(255, 64, 64)},
- {"Orange", OptionsAction::Select, ScreenColor(255, 160, 20)},
- {"Purple", OptionsAction::Select, ScreenColor(204, 153, 255)},
- {"Blue", OptionsAction::Select, ScreenColor(0, 0, 255)},
- {"Teal", OptionsAction::Select, ScreenColor(16, 102, 102)},
- {"Cyan", OptionsAction::Select, ScreenColor(0, 255, 255)},
- {"Ice", OptionsAction::Select, ScreenColor(173, 216, 230)},
- {"Pink", OptionsAction::Select, ScreenColor(255, 105, 180)},
- {"White", OptionsAction::Select, ScreenColor(255, 255, 255)},
- {"Gray", OptionsAction::Select, ScreenColor(128, 128, 128)},
- };
-
- constexpr size_t colorCount = sizeof(colorOptions) / sizeof(colorOptions[0]);
- static std::array colorLabels{};
-
- auto bannerOptions = createStaticBannerOptions(
- "Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void {
- if (option.action == OptionsAction::Back) {
- menuQueue = SystemBaseMenu;
- screen->runNow();
- return;
- }
-
- if (!option.hasValue) {
- return;
- }
-
-#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
- HAS_TFT || defined(HACKADAY_COMMUNICATOR)
- const ScreenColor &color = option.value;
- if (color.useVariant) {
- LOG_INFO("Setting color to system default or defined variant");
- } else {
- LOG_INFO("Setting color to %s", option.label);
- }
-
- uint8_t r = color.r;
- uint8_t g = color.g;
- uint8_t b = color.b;
-
- display->setColor(BLACK);
- display->fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
- display->setColor(WHITE);
-
- if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
-#ifdef TFT_MESH_OVERRIDE
- TFT_MESH = TFT_MESH_OVERRIDE;
-#else
- TFT_MESH = COLOR565(255, 255, 128);
-#endif
- } else {
- TFT_MESH = COLOR565(r, g, b);
- }
-
-#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190)
- static_cast(screen->getDisplayDevice())->setRGB(TFT_MESH);
-#endif
-
- screen->setFrames(graphics::Screen::FOCUS_SYSTEM);
- if (color.useVariant || (r == 0 && g == 0 && b == 0)) {
- uiconfig.screen_rgb_color = 0;
- } else {
- uiconfig.screen_rgb_color =
- (static_cast(r) << 16) | (static_cast(g) << 8) | static_cast(b);
- }
- LOG_INFO("Storing Value of %d to uiconfig.screen_rgb_color", uiconfig.screen_rgb_color);
- saveUIConfig();
-#endif
- });
-
- int initialSelection = 0;
- if (uiconfig.screen_rgb_color == 0) {
- initialSelection = 1;
- } else {
- uint32_t currentColor = uiconfig.screen_rgb_color;
- for (size_t i = 0; i < colorCount; ++i) {
- if (!colorOptions[i].hasValue) {
- continue;
- }
- const ScreenColor &color = colorOptions[i].value;
- if (color.useVariant) {
- continue;
- }
- uint32_t encoded =
- (static_cast(color.r) << 16) | (static_cast(color.g) << 8) | static_cast(color.b);
- if (encoded == currentColor) {
- initialSelection = static_cast(i);
- break;
- }
- }
- }
- bannerOptions.InitialSelected = initialSelection;
-
- screen->showOverlayBanner(bannerOptions);
-}
-
void menuHandler::rebootMenu()
{
static const char *optionsArray[] = {"Back", "Confirm"};
@@ -2318,9 +2214,9 @@ void menuHandler::screenOptionsMenu()
bool hasSupportBrightness = false;
#endif
- enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
- static const char *optionsArray[6] = {"Back"};
- static int optionsEnumArray[6] = {Back};
+ enum optionsNumbers { Back, Brightness, FrameToggles, DisplayUnits, MessageBubbles, Theme };
+ static const char *optionsArray[7] = {"Back"};
+ static int optionsEnumArray[7] = {Back};
int options = 1;
// Only show brightness for B&W displays
@@ -2329,13 +2225,6 @@ void menuHandler::screenOptionsMenu()
optionsEnumArray[options++] = Brightness;
}
- // Only show screen color for TFT displays
-#if defined(HELTEC_MESH_NODE_T114) || defined(HELTEC_VISION_MASTER_T190) || defined(T_DECK) || defined(T_LORA_PAGER) || \
- HAS_TFT || defined(HACKADAY_COMMUNICATOR)
- optionsArray[options] = "Screen Color";
- optionsEnumArray[options++] = ScreenColor;
-#endif
-
optionsArray[options] = "Frame Visibility";
optionsEnumArray[options++] = FrameToggles;
@@ -2345,6 +2234,11 @@ void menuHandler::screenOptionsMenu()
optionsArray[options] = "Message Bubbles";
optionsEnumArray[options++] = MessageBubbles;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ optionsArray[options] = "Theme";
+ optionsEnumArray[options++] = Theme;
+#endif
+
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Display Options";
bannerOptions.optionsArrayPtr = optionsArray;
@@ -2354,9 +2248,6 @@ void menuHandler::screenOptionsMenu()
if (selected == Brightness) {
menuHandler::menuQueue = menuHandler::BrightnessPicker;
screen->runNow();
- } else if (selected == ScreenColor) {
- menuHandler::menuQueue = menuHandler::TftColorMenuPicker;
- screen->runNow();
} else if (selected == FrameToggles) {
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
@@ -2366,6 +2257,9 @@ void menuHandler::screenOptionsMenu()
} else if (selected == MessageBubbles) {
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
screen->runNow();
+ } else if (selected == Theme) {
+ menuHandler::menuQueue = menuHandler::ThemeMenu;
+ screen->runNow();
} else {
menuQueue = SystemBaseMenu;
screen->runNow();
@@ -2649,6 +2543,53 @@ void menuHandler::messageBubblesMenu()
screen->showOverlayBanner(bannerOptions);
}
+void menuHandler::themeMenu()
+{
+ // Build menu dynamically from the theme table.
+ // Only visible themes appear!
+ // Slot budget: 1 for "Back" + up to kMaxThemesInMenu visible themes.
+ // Bump kMaxThemesInMenu if you add more themes than will fit here.
+ constexpr size_t kMaxThemesInMenu = 15;
+ const size_t visibleCount = getVisibleThemeCount();
+ static const char *optionsArray[kMaxThemesInMenu + 1] = {"Back"};
+ const size_t shownCount = (visibleCount < kMaxThemesInMenu) ? visibleCount : kMaxThemesInMenu;
+ const int options = static_cast(shownCount) + 1; // +1 for Back
+
+ for (size_t i = 0; i < shownCount; i++) {
+ optionsArray[i + 1] = getVisibleThemeByIndex(i).name;
+ }
+
+ BannerOverlayOptions bannerOptions;
+ bannerOptions.message = "Theme";
+ bannerOptions.optionsArrayPtr = optionsArray;
+ bannerOptions.optionsCount = options;
+
+ // Highlight the currently active theme (visible index + 1 for the Back
+ // offset). If the active theme is hidden, leave selection on "Back".
+ const size_t activeVisible = getActiveVisibleThemeIndex();
+ bannerOptions.InitialSelected = (activeVisible == SIZE_MAX) ? 0 : static_cast(activeVisible) + 1;
+
+ bannerOptions.bannerCallback = [](int selected) -> void {
+ if (selected == 0) {
+ // Back
+ menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
+ screen->runNow();
+ } else {
+ // Selection is an index into the VISIBLE themes (1-based, slot 0 is Back).
+ const size_t visibleIdx = static_cast(selected - 1);
+ if (visibleIdx < getVisibleThemeCount()) {
+ // Persist the theme's uniqueIdentifier so boot-time
+ // resolveThemeIndex() can restore this theme on next startup.
+ uiconfig.screen_rgb_color = COLOR565(255, 255, (getVisibleThemeByIndex(visibleIdx).uniqueIdentifier & 0x1F) << 3);
+ loadThemeDefaults();
+ saveUIConfig();
+ screen->runNow();
+ }
+ }
+ };
+ screen->showOverlayBanner(bannerOptions);
+}
+
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
{
if (menuQueue != MenuNone)
@@ -2724,9 +2665,6 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MuiPicker:
switchToMUIMenu();
break;
- case TftColorMenuPicker:
- TFTColorPickerMenu(display);
- break;
case BrightnessPicker:
BrightnessPickerMenu();
break;
@@ -2799,6 +2737,9 @@ void menuHandler::handleMenuSwitch(OLEDDisplay *display)
case MessageBubblesMenu:
messageBubblesMenu();
break;
+ case ThemeMenu:
+ themeMenu();
+ break;
}
menuQueue = MenuNone;
}
@@ -2810,4 +2751,4 @@ void menuHandler::saveUIConfig()
} // namespace graphics
-#endif
\ No newline at end of file
+#endif
diff --git a/src/graphics/draw/MenuHandler.h b/src/graphics/draw/MenuHandler.h
index 4a0360412..3ac9e606e 100644
--- a/src/graphics/draw/MenuHandler.h
+++ b/src/graphics/draw/MenuHandler.h
@@ -30,7 +30,6 @@ class menuHandler
ResetNodeDbMenu,
BuzzerModeMenuPicker,
MuiPicker,
- TftColorMenuPicker,
BrightnessPicker,
RebootMenu,
ShutdownMenu,
@@ -55,7 +54,8 @@ class menuHandler
NodeNameLengthMenu,
FrameToggles,
DisplayUnits,
- MessageBubblesMenu
+ MessageBubblesMenu,
+ ThemeMenu
};
static screenMenus menuQueue;
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
@@ -89,7 +89,6 @@ class menuHandler
static void GPSPositionBroadcastMenu();
static void BuzzerModeMenu();
static void switchToMUIMenu();
- static void TFTColorPickerMenu(OLEDDisplay *display);
static void nodeListMenu();
static void resetNodeDBMenu();
static void BrightnessPickerMenu();
@@ -110,6 +109,7 @@ class menuHandler
static void frameTogglesMenu();
static void displayUnitsMenu();
static void messageBubblesMenu();
+ static void themeMenu();
static void textMessageMenu();
private:
@@ -136,23 +136,10 @@ template struct MenuOption {
MenuOption(const char *labelIn, OptionsAction actionIn) : label(labelIn), action(actionIn), hasValue(false), value() {}
};
-struct ScreenColor {
- uint8_t r;
- uint8_t g;
- uint8_t b;
- bool useVariant;
-
- explicit ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
- : r(rIn), g(gIn), b(bIn), useVariant(variantIn)
- {
- }
-};
-
using RadioPresetOption = MenuOption;
using LoraRegionOption = MenuOption;
using TimezoneOption = MenuOption;
using CompassOption = MenuOption;
-using ScreenColorOption = MenuOption;
using GPSToggleOption = MenuOption;
using GPSFormatOption = MenuOption;
using NodeNameOption = MenuOption;
diff --git a/src/graphics/draw/MessageRenderer.cpp b/src/graphics/draw/MessageRenderer.cpp
index 2fd9bf541..2260c57df 100644
--- a/src/graphics/draw/MessageRenderer.cpp
+++ b/src/graphics/draw/MessageRenderer.cpp
@@ -11,6 +11,8 @@
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
#include "graphics/TimeFormatters.h"
#include "graphics/emotes.h"
#include "main.h"
@@ -254,6 +256,76 @@ struct MessageBlock {
bool mine;
};
+#if GRAPHICS_TFT_COLORING_ENABLED
+static void setDarkModeBubbleRoleColors(uint32_t themeId, bool mine)
+{
+ uint16_t bubbleOnColor;
+ uint16_t bubbleOffColor;
+
+ if (themeId == ThemeID::Blue) {
+ bubbleOnColor = mine ? TFTPalette::Navy : TFTPalette::White;
+ bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DeepBlue;
+ } else {
+ bubbleOnColor = mine ? TFTPalette::Black : getThemeBodyFg();
+ bubbleOffColor = mine ? TFTPalette::SkyBlue : TFTPalette::DarkGray;
+ }
+
+ setTFTColorRole(TFTColorRole::ActionMenuBody, bubbleOnColor, bubbleOffColor);
+}
+
+static void registerRoundedBubbleFillRegion(int x, int y, int w, int h, int radius)
+{
+ if (w <= 0 || h <= 0) {
+ return;
+ }
+
+ if (radius <= 0 || w < 3 || h < 3) {
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
+ return;
+ }
+
+ // Keep region count low so we don't churn MAX_TFT_COLOR_REGIONS while
+ // scrolling long message lists (which can flatten older bubble corners).
+ int capRows = 0;
+ if (radius >= 4 && h >= 5) {
+ capRows = 2; // 5 regions total (2 top caps + middle + 2 bottom caps)
+ } else if (radius >= 2 && h >= 3) {
+ capRows = 1; // 3 regions total
+ }
+ if (capRows <= 0) {
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, y, w, h);
+ return;
+ }
+
+ for (int row = 0; row < capRows; ++row) {
+ int inset = 0;
+ if (radius >= 4) {
+ inset = (row == 0) ? 2 : 1;
+ } else if (radius >= 2) {
+ inset = 1;
+ }
+ const int stripW = w - (inset * 2);
+ if (stripW <= 0) {
+ continue;
+ }
+
+ const int topY = y + row;
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, topY, stripW, 1);
+
+ const int bottomY = y + h - 1 - row;
+ if (bottomY != topY) {
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, x + inset, bottomY, stripW, 1);
+ }
+ }
+
+ const int middleY = y + capRows;
+ const int middleH = h - (capRows * 2);
+ if (middleH > 0) {
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, x, middleY, w, middleH);
+ }
+}
+#endif
+
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
{
if (isHeaderLine) {
@@ -648,6 +720,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int contentBottom = scrollBottom; // already excludes nav line
const int rightEdge = SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN;
const int bubbleGapY = std::max(1, MESSAGE_BLOCK_GAP / 2);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const uint32_t themeId = getActiveTheme().id;
+ // Blue is a dark variant but uses full frame inversion, Keep it on the same filled bubble style as Default Dark.
+ const bool useDarkModeBubbleFill = showBubbles && (!isThemeFullFrameInvert() || themeId == ThemeID::Blue);
+#endif
std::vector lineTop;
lineTop.resize(cachedLines.size());
@@ -686,6 +763,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
int bottomY = visualBottom + BUBBLE_PAD_Y;
+ // On high-res screens, keep a 1px gap under the header
+ if (currentResolution == ScreenResolution::High) {
+ const int minTopY = contentTop + 1;
+ if (topY < minTopY) {
+ // Preserve bubble height when we push it down from the header.
+ const int shift = minTopY - topY;
+ topY = minTopY;
+ bottomY += shift;
+ }
+ }
+
if (bi + 1 < blocks.size()) {
int nextHeaderIndex = (int)blocks[bi + 1].start;
int nextTop = lineTop[nextHeaderIndex];
@@ -735,24 +823,56 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
const int by = topY;
const int bw = bubbleW;
const int bh = bubbleH;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const bool drawBubbleOutline = !useDarkModeBubbleFill;
+#else
+ const bool drawBubbleOutline = true;
+#endif
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (useDarkModeBubbleFill) {
+ setDarkModeBubbleRoleColors(themeId, b.mine);
+ registerRoundedBubbleFillRegion(bx, by, bw, bh, r);
+ }
+#endif
- // Draw the 4 corner arcs using drawCircleQuads
- display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
- display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
- display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
- display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
+ if (drawBubbleOutline) {
+ // Draw the 4 corner arcs using drawCircleQuads
+ display->drawCircleQuads(bx + r, by + r, r, 0x2); // Top-left
+ display->drawCircleQuads(bx + bw - r - 1, by + r, r, 0x1); // Top-right
+ display->drawCircleQuads(bx + r, by + bh - r - 1, r, 0x4); // Bottom-left
+ display->drawCircleQuads(bx + bw - r - 1, by + bh - r - 1, r, 0x8); // Bottom-right
- // Draw the 4 edges between corners
- display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
- display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
- display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
- display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
+ // Draw the 4 edges between corners
+ display->drawHorizontalLine(bx + r, by, bw - 2 * r); // Top edge
+ display->drawHorizontalLine(bx + r, by + bh - 1, bw - 2 * r); // Bottom edge
+ display->drawVerticalLine(bx, by + r, bh - 2 * r); // Left edge
+ display->drawVerticalLine(bx + bw - 1, by + r, bh - 2 * r); // Right edge
+ }
} else if (bubbleW > 1 && bubbleH > 1) {
// Fallback to simple rectangle for very small bubbles
- display->drawRect(bubbleX, topY, bubbleW, bubbleH);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const bool drawBubbleOutline = !useDarkModeBubbleFill;
+#else
+ const bool drawBubbleOutline = true;
+#endif
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (useDarkModeBubbleFill) {
+ setDarkModeBubbleRoleColors(themeId, b.mine);
+ registerTFTColorRegion(TFTColorRole::ActionMenuBody, bubbleX, topY, bubbleW, bubbleH);
+ }
+#endif
+ if (drawBubbleOutline) {
+ display->drawRect(bubbleX, topY, bubbleW, bubbleH);
+ }
}
}
} // end if (showBubbles)
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (useDarkModeBubbleFill) {
+ // Restore theme role defaults so other screens keep their intended palette.
+ loadThemeDefaults();
+ }
+#endif
// Render visible lines
int lineY = yOffset;
@@ -772,7 +892,7 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
headerX = x + textIndent;
}
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
- false);
+ true);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
diff --git a/src/graphics/draw/NodeListRenderer.cpp b/src/graphics/draw/NodeListRenderer.cpp
index 201d267e3..00ae74b58 100644
--- a/src/graphics/draw/NodeListRenderer.cpp
+++ b/src/graphics/draw/NodeListRenderer.cpp
@@ -11,6 +11,8 @@
#include "gps/RTC.h" // for getTime() function
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "meshUtils.h"
#include
@@ -213,6 +215,33 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
}
}
+static inline void applyFavoriteNodeNameColor(OLEDDisplay *display, const meshtastic_NodeInfoLite *node, const char *nodeName,
+ int16_t nameX, int16_t y, int nameMaxWidth)
+{
+ if (!display || !node || !node->is_favorite || !isTFTColoringEnabled() || !nodeName) {
+ return;
+ }
+
+ const int textWidth = UIRenderer::measureStringWithEmotes(display, nodeName);
+ const int regionWidth = min(textWidth, max(0, nameMaxWidth));
+ if (regionWidth <= 0) {
+ return;
+ }
+
+ // Node list rows can begin a couple of pixels inside header space.
+ // Clamp favorite-name color region below the header to avoid black overlap there.
+ const int16_t minContentY = static_cast(FONT_HEIGHT_SMALL + 1);
+ const int16_t regionY = max(y, minContentY);
+ const int16_t yClip = regionY - y;
+ const int16_t regionHeight = static_cast(FONT_HEIGHT_SMALL - yClip);
+ if (regionHeight <= 0) {
+ return;
+ }
+
+ setAndRegisterTFTColorRole(TFTColorRole::FavoriteNode, TFTPalette::Yellow, TFTPalette::Black, nameX, regionY, regionWidth,
+ regionHeight);
+}
+
// =============================
// Entry Renderers
// =============================
@@ -227,6 +256,9 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
+#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -286,6 +318,9 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
+#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -315,6 +350,19 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
int barStartX = x + barsXOffset;
int barStartY = y + 1 + (FONT_HEIGHT_SMALL / 2) + 2;
+ if (bars > 0) {
+ uint16_t signalBarsColor = TFTPalette::Bad;
+ if (bars >= 3) {
+ signalBarsColor = TFTPalette::Good;
+ } else if (bars == 2) {
+ signalBarsColor = TFTPalette::Medium;
+ }
+
+ // Highest bar reaches 6 px in this renderer.
+ setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, barStartX, barStartY - 6,
+ (kBarCount * kBarWidth) + ((kBarCount - 1) * kBarGap), 6);
+ }
+
for (int b = 0; b < kBarCount; b++) {
if (b < bars) {
int height = (b * 2);
@@ -350,6 +398,9 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
+#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -455,6 +506,9 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ applyFavoriteNodeNameColor(display, node, nodeName, nameX, y, nameMaxWidth);
+#endif
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -710,6 +764,9 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
display->setColor(WHITE);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
+#endif
// Text
display->drawString(boxLeft + padding, boxTop + padding, buf);
diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp
index 31eb2c3c8..cca60d1e2 100644
--- a/src/graphics/draw/NotificationRenderer.cpp
+++ b/src/graphics/draw/NotificationRenderer.cpp
@@ -7,6 +7,8 @@
#include "UIRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
+#include "graphics/TFTColorRegions.h"
+#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "input/RotaryEncoderInterruptImpl1.h"
#include "input/UpDownInterruptImpl1.h"
@@ -608,6 +610,9 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1);
display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1);
display->setColor(WHITE);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ registerTFTActionMenuRegions(boxLeft, boxTop, boxWidth, boxHeight);
+#endif
// Draw Content
int16_t lineY = boxTop + vPadding;
@@ -630,7 +635,21 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) {
background_yOffset = -1;
}
- display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
+ const int16_t titleBarY = boxTop + 1;
+ const int16_t titleBarHeight = effectiveLineHeight - background_yOffset;
+ display->fillRect(boxLeft, titleBarY, boxWidth, titleBarHeight);
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (alertBannerOptions > 0) {
+ const uint16_t titleTextColor =
+ (getActiveTheme().id == ThemeID::DefaultLight) ? TFTPalette::Black : getThemeHeaderText();
+ // Keep title role away from border/corner pixels so rounded-corner masks are not remapped to the title text
+ // color.
+ if (boxWidth > 2 && titleBarHeight > 0) {
+ setAndRegisterTFTColorRole(TFTColorRole::ActionMenuTitle, getThemeHeaderBg(), titleTextColor, boxLeft + 1,
+ titleBarY, boxWidth - 2, titleBarHeight);
+ }
+ }
+#endif
display->setColor(BLACK);
int yOffset = 3;
if (current_notification_type == notificationTypeEnum::node_picker) {
@@ -650,6 +669,7 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
const int barSpacing = 2;
const int barHeightStep = 2;
const int gap = 6;
+ const int maxBarHeight = totalBars * barHeightStep;
int textWidth = display->getStringWidth(lineBuffer, strlen(lineBuffer), true);
int barsWidth = totalBars * barWidth + (totalBars - 1) * barSpacing + gap;
@@ -664,6 +684,20 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int baseX = groupStartX + textWidth + gap;
int baseY = lineY + effectiveLineHeight - 1;
+#if GRAPHICS_TFT_COLORING_ENABLED
+ if (graphics::bannerSignalBars > 0) {
+ uint16_t signalBarsColor = TFTPalette::Medium;
+ if (graphics::bannerSignalBars <= 1) {
+ signalBarsColor = TFTPalette::Bad;
+ } else if (graphics::bannerSignalBars >= 4) {
+ signalBarsColor = TFTPalette::Good;
+ }
+ const int activeBars = min(graphics::bannerSignalBars, totalBars);
+ const int regionWidth = activeBars * barWidth + (activeBars - 1) * barSpacing;
+ setAndRegisterTFTColorRole(TFTColorRole::SignalBars, signalBarsColor, TFTPalette::Black, baseX,
+ baseY - maxBarHeight, regionWidth, maxBarHeight);
+ }
+#endif
for (int b = 0; b < totalBars; b++) {
int barHeight = (b + 1) * barHeightStep;
int x = baseX + b * (barWidth + barSpacing);
diff --git a/src/graphics/draw/UIRenderer.cpp b/src/graphics/draw/UIRenderer.cpp
index 4bf4df4bf..b75bcd17b 100644
--- a/src/graphics/draw/UIRenderer.cpp
+++ b/src/graphics/draw/UIRenderer.cpp
@@ -13,6 +13,8 @@
#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"
@@ -30,6 +32,7 @@ namespace graphics
{
NodeNum UIRenderer::currentFavoriteNodeNum = 0;
std::vector graphics::UIRenderer::favoritedNodes;
+static bool gBootSplashBoldPass = false;
static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
{
@@ -41,6 +44,347 @@ static inline void drawSatelliteIcon(OLEDDisplay *display, int16_t x, int16_t y)
}
}
+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(x);
+ outY = static_cast(y);
+}
+
+static float getCompassRingAngleOffset(float heading)
+{
+ return (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING) ? -heading : 0.0f;
+}
+
+static inline StandardCompassNeedlePoints computeStandardCompassNeedlePoints(int16_t compassX, int16_t compassY,
+ uint16_t compassDiam, float headingRadian,
+ float centerGapPx)
+{
+ // Standard-style symmetric needle with a narrow waist and a tiny center gap
+ // between north/south halves to prevent seam bleed while rotating.
+ const float scaledDiam = compassDiam * 0.76f;
+ const float gapNormHalf = (centerGapPx * 0.5f) / scaledDiam;
+ const float sinHeading = sinf(headingRadian);
+ const float cosHeading = cosf(headingRadian);
+
+ StandardCompassNeedlePoints points{};
+ transformNeedlePoint(0.0f, -0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northTipX, points.northTipY);
+ transformNeedlePoint(-0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northLeftX,
+ points.northLeftY);
+ transformNeedlePoint(0.09f, -gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.northRightX,
+ points.northRightY);
+ transformNeedlePoint(0.0f, 0.5f, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southTipX, points.southTipY);
+ transformNeedlePoint(-0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southLeftX,
+ points.southLeftY);
+ transformNeedlePoint(0.09f, gapNormHalf, sinHeading, cosHeading, scaledDiam, compassX, compassY, points.southRightX,
+ points.southRightY);
+ return points;
+}
+
+static inline void drawCompassNorthOnlyLabel(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
+ float heading)
+{
+ int16_t labelRadius = compassRadius;
+ // CompassRenderer::drawCompassNorth() expands radius on high-res by +4.
+ // Compensate so label placement stays aligned with the current UI layout.
+ if (currentResolution == ScreenResolution::High && labelRadius > 4) {
+ labelRadius -= 4;
+ }
+ graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, heading, labelRadius);
+}
+
+static inline void drawMonoCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
+{
+ const StandardCompassNeedlePoints points =
+ computeStandardCompassNeedlePoints(compassX, compassY, static_cast(compassRadius * 2), -heading, 0.0f);
+
+#ifdef USE_EINK
+ display->setColor(WHITE);
+ display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
+ points.northRightY);
+ display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
+ points.southRightY);
+#else
+ // OLED variant: same needle geometry as TFT, but monochrome contrast.
+ display->setColor(WHITE);
+ display->fillTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
+ points.northRightY);
+ display->setColor(BLACK);
+ display->fillTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
+ points.southRightY);
+ // Keep a white outline so the black half remains visible on dark backgrounds.
+ display->setColor(WHITE);
+ display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
+ points.southRightY);
+#endif
+
+ display->drawCircle(compassX, compassY, compassRadius);
+ drawCompassNorthOnlyLabel(display, compassX, compassY, compassRadius, heading);
+}
+
+#if GRAPHICS_TFT_COLORING_ENABLED
+struct NeedleColorBand {
+ int16_t xMin;
+ int16_t xMax;
+ int16_t yMin;
+ int16_t yMax;
+ bool used;
+};
+
+static constexpr int kNeedleBandCount = 6;
+
+static inline void registerNeedleSpan(NeedleColorBand (&bands)[kNeedleBandCount], int16_t bandTop, int16_t bandHeight, int16_t y,
+ int16_t a, int16_t b)
+{
+ if (a > b) {
+ const int16_t t = a;
+ a = b;
+ b = t;
+ }
+
+ int band = (static_cast(y - bandTop) * kNeedleBandCount) / bandHeight;
+ if (band < 0) {
+ band = 0;
+ } else if (band >= kNeedleBandCount) {
+ band = kNeedleBandCount - 1;
+ }
+
+ NeedleColorBand ®ion = bands[band];
+ if (!region.used) {
+ region.used = true;
+ region.xMin = a;
+ region.xMax = b;
+ region.yMin = y;
+ region.yMax = y;
+ return;
+ }
+ if (a < region.xMin)
+ region.xMin = a;
+ if (b > region.xMax)
+ region.xMax = b;
+ if (y < region.yMin)
+ region.yMin = y;
+ if (y > region.yMax)
+ region.yMax = y;
+}
+
+static void drawNeedleHalfAndRegisterBands(OLEDDisplay *display, int16_t x0, int16_t y0, int16_t x1, int16_t y1, int16_t x2,
+ int16_t y2, uint16_t onColor, uint16_t offColor)
+{
+ // Important for maintainers:
+ // The compass needle rotates continuously, so color-region registration must
+ // track triangle shape (or a close approximation), not only one AABB.
+ // Coarse rectangles can leak south color into north at diagonal angles.
+ // Keep this banded approach unless a replacement preserves per-angle coverage.
+ // Performance note: draw the triangle once via fillTriangle(), then build
+ // band regions in software for accurate color-role registration.
+ display->fillTriangle(x0, y0, x1, y1, x2, y2);
+
+ if (y0 > y1)
+ swapPoint(x0, y0, x1, y1);
+ if (y1 > y2)
+ swapPoint(x1, y1, x2, y2);
+ if (y0 > y1)
+ swapPoint(x0, y0, x1, y1);
+
+ NeedleColorBand bands[kNeedleBandCount] = {};
+
+ const int16_t bandTop = y0;
+ const int16_t bandBottom = y2;
+ const int16_t bandHeight = (bandBottom >= bandTop) ? static_cast(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(y1 - 1);
+ for (; y <= last; y++) {
+ const int16_t a = static_cast(x0 + ((dy01 != 0) ? (sa / dy01) : 0));
+ const int16_t b = static_cast(x0 + ((dy02 != 0) ? (sb / dy02) : 0));
+ sa += dx01;
+ sb += dx02;
+ registerNeedleSpan(bands, bandTop, bandHeight, y, a, b);
+ }
+
+ sa = dx12 * static_cast(y - y1);
+ sb = dx02 * static_cast(y - y0);
+ for (; y <= y2; y++) {
+ const int16_t a = static_cast(x1 + ((dy12 != 0) ? (sa / dy12) : 0));
+ const int16_t b = static_cast(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(radius * sinNorth);
+ const int16_t nY = compassY - static_cast(radius * cosNorth);
+ const int16_t eX = compassX + static_cast(radius * cosNorth);
+ const int16_t eY = compassY + static_cast(radius * sinNorth);
+ const int16_t sX = compassX - static_cast(radius * sinNorth);
+ const int16_t sY = compassY + static_cast(radius * cosNorth);
+ const int16_t wX = compassX - static_cast(radius * cosNorth);
+ const int16_t wY = compassY - static_cast(radius * sinNorth);
+
+ display->setFont(FONT_SMALL);
+ display->setTextAlignment(TEXT_ALIGN_CENTER);
+ const int16_t labelWidth = static_cast(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((compassRadius - 1) * sinAngle);
+ const int16_t yOuter = compassY - static_cast((compassRadius - 1) * cosAngle);
+ const int16_t xInner = compassX + static_cast((compassRadius - tickLen) * sinAngle);
+ const int16_t yInner = compassY - static_cast((compassRadius - tickLen) * cosAngle);
+ display->drawLine(xInner, yInner, xOuter, yOuter);
+
+ // Rotate [sin, cos] by a fixed step instead of recomputing trig 24x/frame.
+ const float nextSin = (sinAngle * cosStep) + (cosAngle * sinStep);
+ const float nextCos = (cosAngle * cosStep) - (sinAngle * sinStep);
+ sinAngle = nextSin;
+ cosAngle = nextCos;
+ isMajor = !isMajor;
+ }
+}
+
+static inline void drawStandardCompassNeedle(OLEDDisplay *display, int16_t compassX, int16_t compassY, uint16_t compassDiam,
+ float headingRadian, uint16_t needleOffColor)
+{
+ const StandardCompassNeedlePoints points =
+ computeStandardCompassNeedlePoints(compassX, compassY, compassDiam, headingRadian, 9.0f);
+
+ display->setColor(WHITE);
+#ifdef USE_EINK
+ display->drawTriangle(points.northTipX, points.northTipY, points.northLeftX, points.northLeftY, points.northRightX,
+ points.northRightY);
+ display->drawTriangle(points.southTipX, points.southTipY, points.southLeftX, points.southLeftY, points.southRightX,
+ points.southRightY);
+#else
+ // NOTE: do not collapse these to one region per half during "flash
+ // optimization". The needle spins, and coarse rectangles will bleed color
+ // across halves at diagonal angles.
+ drawNeedleHalfAndRegisterBands(display, points.northTipX, points.northTipY, points.northLeftX, points.northLeftY,
+ points.northRightX, points.northRightY, TFTPalette::Red, needleOffColor);
+ drawNeedleHalfAndRegisterBands(display, points.southTipX, points.southTipY, points.southLeftX, points.southLeftY,
+ points.southRightX, points.southRightY, TFTPalette::Blue, needleOffColor);
+#endif
+}
+
+static inline void drawTftCompass(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius, float heading)
+{
+ // Compass colors should follow whatever background role is already active at this location.
+ const uint16_t compassBgColor = resolveTFTOffColorAt(compassX, compassY, getThemeBodyBg());
+ const uint16_t compassGlyphColor = TFTPalette::pickReadableMonoFg(compassBgColor);
+ const int16_t pad = 2;
+ const int16_t labelPadX = static_cast(display->getStringWidth("W") / 2) + 2;
+ const int16_t labelPadY = static_cast(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(getTextPositions(display)[1]);
+ int16_t clippedY = boxY;
+ int16_t clippedH = boxH;
+ if (clippedY < bodyTop) {
+ clippedH = static_cast(clippedH - (bodyTop - clippedY));
+ clippedY = bodyTop;
+ }
+ if (clippedH > 0) {
+ registerTFTColorRegionDirect(boxX, clippedY, boxW, clippedH, compassGlyphColor, compassBgColor);
+ }
+
+ drawStandardCompassNeedle(display, compassX, compassY, static_cast(compassRadius * 2), -heading, compassBgColor);
+ display->drawCircle(compassX, compassY, compassRadius);
+ drawCompassDegreeMarkers(display, compassX, compassY, compassRadius, heading);
+ drawCompassCardinalLabels(display, compassX, compassY, compassRadius, heading);
+}
+#endif // GRAPHICS_TFT_COLORING_ENABLED
+
static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_t compassY, const char *statusLine1,
const char *statusLine2)
{
@@ -50,6 +394,99 @@ static void drawCompassStatusText(OLEDDisplay *display, int16_t compassX, int16_
display->setTextAlignment(TEXT_ALIGN_LEFT);
}
+static void drawBearingCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
+ bool showCompass, float myHeading, float bearing, const char *statusLine1,
+ const char *statusLine2)
+{
+ // Shared "favorite node" compass renderer: draw ring, then either heading data or fallback status text.
+ display->drawCircle(compassX, compassY, compassRadius);
+ if (showCompass) {
+ CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
+ CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
+ } else {
+ drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
+ }
+}
+
+static void drawDetailedCompassOrStatus(OLEDDisplay *display, int16_t compassX, int16_t compassY, int16_t compassRadius,
+ bool validHeading, float heading, const char *statusLine1, const char *statusLine2)
+{
+ // Shared "position screen" compass renderer: use mono/TFT path only when heading is valid.
+ if (validHeading) {
+#if GRAPHICS_TFT_COLORING_ENABLED
+ drawTftCompass(display, compassX, compassY, compassRadius, heading);
+#else
+ drawMonoCompass(display, compassX, compassY, compassRadius, heading);
+#endif
+ } else {
+ display->drawCircle(compassX, compassY, compassRadius);
+ drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
+ }
+}
+
+static bool computeLandscapeCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t topY, int16_t *compassX,
+ int16_t *compassY, int16_t *compassRadius)
+{
+ // Keep compass vertically centered in the body area while reserving footer/nav space.
+ const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
+ const int16_t usableHeight = bottomY - topY - 5;
+ int16_t radius = usableHeight / 2;
+ if (radius < 8) {
+ radius = 8;
+ }
+
+ *compassRadius = radius;
+ *compassX = xOffset + SCREEN_WIDTH - radius - 8;
+ *compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
+ return true;
+}
+
+static bool computeBottomCompassPlacement(OLEDDisplay *display, int16_t xOffset, int16_t yBelowContent, int16_t bottomReserved,
+ int16_t margin, int16_t *compassX, int16_t *compassY, int16_t *compassRadius)
+{
+ // Return false when content leaves no room for a readable compass.
+ int availableHeight = SCREEN_HEIGHT - yBelowContent - bottomReserved - margin;
+ if (availableHeight < FONT_HEIGHT_SMALL * 2) {
+ return false;
+ }
+
+ int16_t radius = static_cast(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(yBelowContent + (availableHeight / 2));
+ return true;
+}
+
+static void drawTruncatedStatusLine(OLEDDisplay *display, int16_t x, int16_t y, const std::string &statusText)
+{
+ // Fixed-buffer truncate helper replaces iterative std::string chopping to keep code size down.
+ char rawStatus[96];
+ snprintf(rawStatus, sizeof(rawStatus), " Status: %s", statusText.c_str());
+
+ char clippedStatus[96];
+ UIRenderer::truncateStringWithEmotes(display, rawStatus, clippedStatus, sizeof(clippedStatus), display->getWidth());
+ display->drawString(x, y, clippedStatus);
+}
+
+static int computeChannelUtilizationFill(int percent, int maxFill)
+{
+ // Compact linear fill mapping for the utilization bar.
+ if (percent <= 0 || maxFill <= 0) {
+ return 0;
+ }
+ if (percent >= 100) {
+ return maxFill;
+ }
+ return (maxFill * percent + 50) / 100;
+}
+
void graphics::UIRenderer::rebuildFavoritedNodes()
{
favoritedNodes.clear();
@@ -331,7 +768,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
// === Draw battery/time/mail header (common across screens) ===
- graphics::drawCommonHeader(display, x, y, titlestr);
+ graphics::drawCommonHeader(display, x, y, titlestr, false, false, false, true, TFTPalette::Yellow);
// ===== DYNAMIC ROW STACKING WITH YOUR MACROS =====
// 1. Each potential info row has a macro-defined Y position (not regular increments!).
@@ -349,8 +786,13 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
username = (node->has_user && node->user.long_name[0]) ? node->user.long_name : nullptr;
}
+ // Print node's long name (e.g. "Backpack Node")
if (username) {
- // Print node's long name (e.g. "Backpack Node")
+#if GRAPHICS_TFT_COLORING_ENABLED
+ const int usernameWidth = UIRenderer::measureStringWithEmotes(display, username);
+ setAndRegisterTFTColorRole(TFTColorRole::FavoriteNodeBGHighlight, TFTPalette::Yellow, TFTPalette::Black, x,
+ getTextPositions(display)[line], usernameWidth, FONT_HEIGHT_SMALL);
+#endif
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
}
@@ -370,37 +812,7 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
}
if (found) {
- std::string statusLine = std::string(" Status: ") + found->statusText;
- {
- const int screenW = display->getWidth();
- const int ellipseW = display->getStringWidth("...");
- int w = display->getStringWidth(statusLine.c_str());
-
- // Only do work if it overflows
- if (w > screenW) {
- bool truncated = false;
- if (ellipseW > screenW) {
- statusLine.clear();
- } else {
- while (!statusLine.empty()) {
- // remove one char (byte) at a time
- statusLine.pop_back();
- truncated = true;
-
- // Measure candidate with ellipsis appended
- std::string candidate = statusLine + "...";
- if (display->getStringWidth(candidate.c_str()) <= screenW) {
- statusLine = std::move(candidate);
- break;
- }
- }
- if (statusLine.empty() && ellipseW <= screenW) {
- statusLine = "...";
- }
- }
- }
- }
- display->drawString(x, getTextPositions(display)[line++], statusLine.c_str());
+ drawTruncatedStatusLine(display, x, getTextPositions(display)[line++], found->statusText);
}
}
#endif
@@ -492,6 +904,16 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
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;
@@ -509,23 +931,20 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
}
}
- curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2;
+ 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) {
- // hop label
display->drawString(curX, yPos, "Hop:");
curX += display->getStringWidth("Hop:") + 2;
- // hop count
char hopCount[6];
snprintf(hopCount, sizeof(hopCount), "%d", node->hops_away);
display->drawString(curX, yPos, hopCount);
curX += display->getStringWidth(hopCount) + 2;
- // hop icon
const int iconY = yPos + (FONT_HEIGHT_SMALL - hop_height) / 2;
display->drawXbm(curX, iconY, hop_width, hop_height, hop);
curX += hop_width + 1;
@@ -567,48 +986,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
bool haveDistance = false;
if (nodeDB->hasValidPosition(ourNode) && nodeDB->hasValidPosition(node)) {
- double lat1 = ourNode->position.latitude_i * 1e-7;
- double lon1 = ourNode->position.longitude_i * 1e-7;
- double lat2 = node->position.latitude_i * 1e-7;
- double lon2 = node->position.longitude_i * 1e-7;
- double earthRadiusKm = 6371.0;
- double dLat = (lat2 - lat1) * DEG_TO_RAD;
- double dLon = (lon2 - lon1) * DEG_TO_RAD;
- double a =
- sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * DEG_TO_RAD) * cos(lat2 * DEG_TO_RAD) * sin(dLon / 2) * sin(dLon / 2);
- double c = 2 * atan2(sqrt(a), sqrt(1 - a));
- double distanceKm = earthRadiusKm * c;
-
+ // Use shared meter conversion, then format display units with lightweight integer rounding.
+ const float distanceMeters =
+ GeoCoord::latLongToMeter(DegD(node->position.latitude_i), DegD(node->position.longitude_i),
+ DegD(ourNode->position.latitude_i), DegD(ourNode->position.longitude_i));
if (config.display.units == meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL) {
- double miles = distanceKm * 0.621371;
- if (miles < 0.1) {
- int feet = (int)(miles * 5280);
- if (feet > 0 && feet < 1000) {
- snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet);
- haveDistance = true;
- } else if (feet >= 1000) {
- snprintf(distStr, sizeof(distStr), "%sDistance:¼mi", leftSideSpacing);
- haveDistance = true;
- }
+ const int feet = static_cast((distanceMeters * METERS_TO_FEET) + 0.5f);
+ if (feet > 0 && feet < 1000) {
+ snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet);
+ haveDistance = true;
} else {
- int roundedMiles = (int)(miles + 0.5);
- if (roundedMiles > 0 && roundedMiles < 1000) {
- snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles);
+ 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 {
- if (distanceKm < 1.0) {
- int meters = (int)(distanceKm * 1000);
- if (meters > 0 && meters < 1000) {
- snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters);
- haveDistance = true;
- } else if (meters >= 1000) {
- snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing);
- haveDistance = true;
- }
+ const int meters = static_cast(distanceMeters + 0.5f);
+ if (meters > 0 && meters < 1000) {
+ snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters);
+ haveDistance = true;
} else {
- int km = (int)(distanceKm + 0.5);
+ 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;
@@ -693,64 +1093,29 @@ void UIRenderer::drawFavoriteNode(OLEDDisplay *display, OLEDDisplayUiState *stat
}
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
- if (SCREEN_WIDTH > SCREEN_HEIGHT) {
- if (showCompass || statusLine1) {
+ 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];
- const int16_t bottomY = SCREEN_HEIGHT - (FONT_HEIGHT_SMALL - 1);
- 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;
- const int16_t compassY = topY + (usableHeight / 2) + ((FONT_HEIGHT_SMALL - 1) / 2) + 2;
- const int16_t compassDiam = compassRadius * 2;
-
- display->drawCircle(compassX, compassY, compassRadius);
- if (showCompass) {
- CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
- CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, bearing);
- } else {
- drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
- }
- }
- // else show nothing
- } else {
- // Portrait or square: put compass at the bottom and centered, scaled to fit available space
- if (showCompass || statusLine1) {
- int yBelowContent = (line > 0 && line <= 5) ? (getTextPositions(display)[line - 1] + FONT_HEIGHT_SMALL + 2)
- : getTextPositions(display)[1];
- const int margin = 4;
-// --------- PATCH FOR EINK NAV BAR (ONLY CHANGE BELOW) -----------
+ 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
- // --------- END PATCH FOR EINK NAV BAR -----------
- int availableHeight = SCREEN_HEIGHT - yBelowContent - navBarHeight - margin;
-
- if (availableHeight < FONT_HEIGHT_SMALL * 2)
+ if (!computeBottomCompassPlacement(display, x, yBelowContent, navBarHeight, 4, &compassX, &compassY,
+ &compassRadius)) {
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;
-
- display->drawCircle(compassX, compassY, compassRadius);
- if (showCompass) {
- graphics::CompassRenderer::drawCompassNorth(display, compassX, compassY, myHeading, compassRadius);
- graphics::CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, bearing);
- } else {
- drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
}
}
- // else show nothing
+ drawBearingCompassOrStatus(display, compassX, compassY, compassRadius, showCompass, myHeading, bearing, statusLine1,
+ statusLine2);
}
#endif
graphics::drawCommonFooter(display, x, y);
@@ -776,12 +1141,6 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
// === Content below header ===
- // Determine if we need to show 4 or 5 rows on the screen
- int rows = 4;
- if (!config.bluetooth.enabled) {
- rows = 5;
- }
-
// === First Row: Region / Channel Utilization and Uptime ===
bool origBold = config.display.heading_bold;
config.display.heading_bold = false;
@@ -845,13 +1204,15 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
// === Third Row: Channel Utilization Bluetooth Off (Only If Actually Off) ===
const char *chUtil = "ChUtil:";
char chUtilPercentage[10];
- snprintf(chUtilPercentage, sizeof(chUtilPercentage), "%2.0f%%", airTime->channelUtilizationPercent());
+ int chutil_percent = static_cast(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;
@@ -864,50 +1225,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
if (!config.bluetooth.enabled) {
extraoffset = (currentResolution == ScreenResolution::High) ? 6 : 1;
}
- int chutil_percent = airTime->channelUtilizationPercent();
+ const int raw_chutil_percent = chutil_percent;
- int centerofscreen = SCREEN_WIDTH / 2;
- int total_line_content_width = (chUtil_x + chutil_bar_width + display->getStringWidth(chUtilPercentage) + extraoffset) / 2;
- int starting_position = centerofscreen - total_line_content_width;
- if (!config.bluetooth.enabled) {
- starting_position = 0;
- }
+ // 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 56% or higher to show a full 100% bar, text would still show related percent.
+ // Force 61% or higher to show a full 100% bar, text would still show related percent.
if (chutil_percent >= 61) {
chutil_percent = 100;
}
- // Weighting for nonlinear segments
- float milestone1 = 25;
- float milestone2 = 40;
- float weight1 = 0.45; // Weight for 0–25%
- float weight2 = 0.35; // Weight for 25–40%
- float weight3 = 0.20; // Weight for 40–100%
- float totalWeight = weight1 + weight2 + weight3;
-
- int seg1 = chutil_bar_width * (weight1 / totalWeight);
- int seg2 = chutil_bar_width * (weight2 / totalWeight);
- int seg3 = chutil_bar_width * (weight3 / totalWeight);
-
- int fillRight = 0;
-
- if (chutil_percent <= milestone1) {
- fillRight = (seg1 * (chutil_percent / milestone1));
- } else if (chutil_percent <= milestone2) {
- fillRight = seg1 + (seg2 * ((chutil_percent - milestone1) / (milestone2 - milestone1)));
- } else {
- fillRight = seg1 + seg2 + (seg3 * ((chutil_percent - milestone2) / (100 - milestone2)));
- }
+ 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) {
- display->fillRect(starting_position + chUtil_x, chUtil_y, fillRight, chutil_bar_height);
+#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],
@@ -938,9 +1285,8 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
- UIRenderer::drawStringWithEmotes(
- display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset,
- combinedName, FONT_HEIGHT_SMALL, 1, false);
+ UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++] + yOffset, combinedName,
+ FONT_HEIGHT_SMALL, 1, false);
} else {
// === LongName Centered ===
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
@@ -1116,6 +1462,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
// draw centered icon left to right and centered above the one line of app text
#if defined(M5STACK_UNITC6L)
display->drawXbm(x + (SCREEN_WIDTH - 50) / 2, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits);
+ if (gBootSplashBoldPass) {
+ display->drawXbm(x + (SCREEN_WIDTH - 50) / 2 + 1, y + (SCREEN_HEIGHT - 28) / 2, icon_width, icon_height, icon_bits);
+ }
display->setFont(FONT_MEDIUM);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
@@ -1125,6 +1474,9 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
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];
@@ -1137,6 +1489,10 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
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
@@ -1147,21 +1503,35 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->setFont(FONT_MEDIUM);
display->setTextAlignment(TEXT_ALIGN_LEFT);
const char *title = "meshtastic.org";
- display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, title);
+ display->drawString(x + getStringCenteredX(title), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title);
+ if (gBootSplashBoldPass) {
+ display->drawString(x + getStringCenteredX(title) + 1, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM - 5, title);
+ }
display->setFont(FONT_SMALL);
// Draw region in upper left
- if (upperMsg)
- display->drawString(x + 0, y + 0, upperMsg);
+ 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);
- display->drawString(versionX, y + 0, version);
+ int versionX = x + SCREEN_WIDTH - display->getStringWidth(version) - 5;
+ display->drawString(versionX, y + 5, version);
+ if (gBootSplashBoldPass) {
+ display->drawString(versionX + 1, y + 5, version);
+ }
if (owner.short_name && owner.short_name[0]) {
const char *shortName = owner.short_name;
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
- int shortNameX = x + SCREEN_WIDTH - shortNameW;
- UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
+ 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();
@@ -1169,6 +1539,20 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
#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 *
// ****************************
@@ -1296,45 +1680,23 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
int16_t compassRadius = usableHeight / 2;
if (compassRadius < 8)
compassRadius = 8;
- const int16_t compassDiam = compassRadius * 2;
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;
- display->drawCircle(compassX, compassY, compassRadius);
- if (validHeading) {
- CompassRenderer::drawNodeHeading(display, compassX, compassY, compassDiam, -heading);
-
- // "N" label
- float northAngle = 0;
- if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
- northAngle = -heading;
- float radius = compassRadius;
- int16_t nX = compassX + (radius - 1) * sin(northAngle);
- int16_t nY = compassY - (radius - 1) * cos(northAngle);
- int16_t nLabelWidth = display->getStringWidth("N") + 2;
- int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
-
- display->setColor(BLACK);
- display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
- display->setColor(WHITE);
- display->setFont(FONT_SMALL);
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
- } else {
- drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
- }
+ 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!
- int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2;
- const int margin = 4;
- int availableHeight =
+ const int yBelowContent = textPos[5] + FONT_HEIGHT_SMALL + 2;
#if defined(USE_EINK)
- SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink
+ const int margin = 4;
+ int availableHeight = SCREEN_HEIGHT - yBelowContent - 24; // Leave extra space for nav bar on E-Ink
#else
- SCREEN_HEIGHT - yBelowContent - margin;
+ const int margin = 4;
+ int availableHeight = SCREEN_HEIGHT - yBelowContent - margin;
#endif
if (availableHeight < FONT_HEIGHT_SMALL * 2)
@@ -1349,29 +1711,8 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
int compassX = x + SCREEN_WIDTH / 2;
int compassY = yBelowContent + availableHeight / 2;
- display->drawCircle(compassX, compassY, compassRadius);
- if (validHeading) {
- CompassRenderer::drawNodeHeading(display, compassX, compassY, compassRadius * 2, -heading);
-
- // "N" label
- float northAngle = 0;
- if (uiconfig.compass_mode != meshtastic_CompassMode_FIXED_RING)
- northAngle = -heading;
- float radius = compassRadius;
- int16_t nX = compassX + (radius - 1) * sin(northAngle);
- int16_t nY = compassY - (radius - 1) * cos(northAngle);
- int16_t nLabelWidth = display->getStringWidth("N") + 2;
- int16_t nLabelHeightBox = FONT_HEIGHT_SMALL + 1;
-
- display->setColor(BLACK);
- display->fillRect(nX - nLabelWidth / 2, nY - nLabelHeightBox / 2, nLabelWidth, nLabelHeightBox);
- display->setColor(WHITE);
- display->setFont(FONT_SMALL);
- display->setTextAlignment(TEXT_ALIGN_CENTER);
- display->drawString(nX, nY - FONT_HEIGHT_SMALL / 2, "N");
- } else {
- drawCompassStatusText(display, compassX, compassY, statusLine1, statusLine2);
- }
+ drawDetailedCompassOrStatus(display, compassX, compassY, compassRadius, validHeading, heading, statusLine1,
+ statusLine2);
}
}
#endif
@@ -1443,18 +1784,21 @@ void UIRenderer::drawOEMBootScreen(OLEDDisplay *display, OLEDDisplayUiState *sta
#endif
// Navigation bar overlay implementation
-static int8_t lastFrameIndex = -1;
+static int16_t lastFrameIndex = -1;
static uint32_t lastFrameChangeTime = 0;
constexpr uint32_t ICON_DISPLAY_DURATION_MS = 2000;
// cppcheck-suppress constParameterPointer; signature must match OverlayCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state)
{
- int currentFrame = state->currentFrame;
+ 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 (currentFrame != lastFrameIndex) {
- lastFrameIndex = currentFrame;
+ if (frameToHighlight != lastFrameIndex) {
+ lastFrameIndex = frameToHighlight;
lastFrameChangeTime = millis();
}
@@ -1473,15 +1817,15 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
usableWidth = iconSize;
const size_t iconsPerPage = usableWidth / (iconSize + spacing);
- const size_t currentPage = currentFrame / iconsPerPage;
+ 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;
- bool navBarVisible = millis() - lastFrameChangeTime <= ICON_DISPLAY_DURATION_MS;
- int y = navBarVisible ? (SCREEN_HEIGHT - iconSize - 1) : SCREEN_HEIGHT;
+ 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
@@ -1512,25 +1856,54 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
// Pre-calculate bounding rect
const int rectX = xStart - 2 - bigOffset;
+ const int rectY = y - 2;
const int rectWidth = totalWidth + 4 + (bigOffset * 2);
const int rectHeight = iconSize + 6;
// Clear background and draw border
display->setColor(BLACK);
- display->fillRect(rectX + 1, y - 2, rectWidth - 2, rectHeight - 2);
+#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(TFTColorRole::NavigationBar)];
+ const auto &navArrowRole = theme.roles[static_cast(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);
- display->drawRect(rectX, y - 2, rectWidth, rectHeight);
// 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(currentFrame));
+ const bool isActive = (i == static_cast(frameToHighlight));
if (isActive) {
+#if GRAPHICS_TFT_COLORING_ENABLED
+ // Active icon inverts on TFT: white chip with black glyph.
+ // Keep the buffer visibly different too, so dirty-rect updates include this region.
+ registerTFTColorRegion(TFTColorRole::NavigationBar, x - 1, y - 1, iconSize + 2, iconSize + 2);
+ display->setColor(WHITE);
+ display->fillRect(x - 1, y - 1, iconSize + 2, iconSize + 2);
+ display->setColor(BLACK);
+#else
display->setColor(WHITE);
display->fillRect(x - 2, y - 2, iconSize + 4, iconSize + 4);
display->setColor(BLACK);
+#endif
}
if (currentResolution == ScreenResolution::High) {
@@ -1544,22 +1917,17 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
}
}
- // Compact arrow drawer
+ 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) {
- display->setColor(WHITE);
-
- const int offset = (currentResolution == ScreenResolution::High) ? 3 : 1;
- const int halfH = rectHeight / 2;
-
- const int top = (y - 2) + (rectHeight - halfH) / 2;
- const int bottom = top + halfH - 1;
- const int midY = top + (halfH / 2);
-
- const int maxW = 4;
-
- // Determine left X coordinate
- int baseX = rightSide ? (rectX + rectWidth + offset) : // right arrow
- (rectX - offset - 1); // left arrow
+ int baseX = rightSide ? (rectX + rectWidth + offset) : (rectX - offset - 1);
for (int yy = top; yy <= bottom; yy++) {
int dist = abs(yy - midY);
@@ -1574,21 +1942,43 @@ void UIRenderer::drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *sta
}
}
};
+
// Right arrow
- if (pageEnd < totalIcons) {
+ 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 (pageStart > 0) {
+ 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, y - 2, 1, 1);
- display->drawRect(rectX + rectWidth - 1, y - 2, 1, 1);
+ 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)
diff --git a/src/graphics/draw/UIRenderer.h b/src/graphics/draw/UIRenderer.h
index a705d944d..0aeace42e 100644
--- a/src/graphics/draw/UIRenderer.h
+++ b/src/graphics/draw/UIRenderer.h
@@ -54,6 +54,8 @@ class UIRenderer
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+ static void drawBootIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
+
// Icon and screen drawing functions
static void drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
diff --git a/src/motion/AccelerometerThread.h b/src/motion/AccelerometerThread.h
old mode 100644
new mode 100755
diff --git a/src/motion/BMA423Sensor.cpp b/src/motion/BMA423Sensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/BMA423Sensor.h b/src/motion/BMA423Sensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/BMX160Sensor.cpp b/src/motion/BMX160Sensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/BMX160Sensor.h b/src/motion/BMX160Sensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/ICM20948Sensor.cpp b/src/motion/ICM20948Sensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/ICM20948Sensor.h b/src/motion/ICM20948Sensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/LIS3DHSensor.cpp b/src/motion/LIS3DHSensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/LIS3DHSensor.h b/src/motion/LIS3DHSensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/LSM6DS3Sensor.cpp b/src/motion/LSM6DS3Sensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/LSM6DS3Sensor.h b/src/motion/LSM6DS3Sensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/MPU6050Sensor.cpp b/src/motion/MPU6050Sensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/MPU6050Sensor.h b/src/motion/MPU6050Sensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/MotionSensor.cpp b/src/motion/MotionSensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/MotionSensor.h b/src/motion/MotionSensor.h
old mode 100644
new mode 100755
diff --git a/src/motion/STK8XXXSensor.cpp b/src/motion/STK8XXXSensor.cpp
old mode 100644
new mode 100755
diff --git a/src/motion/STK8XXXSensor.h b/src/motion/STK8XXXSensor.h
old mode 100644
new mode 100755
diff --git a/src/sleep.cpp b/src/sleep.cpp
index a2a943a1f..792781f6d 100644
--- a/src/sleep.cpp
+++ b/src/sleep.cpp
@@ -79,7 +79,7 @@ RTC_DATA_ATTR int bootCount = 0;
*/
void setCPUFast(bool on)
{
-#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT
+#if defined(ARCH_ESP32) && HAS_WIFI && !HAS_TFT && !defined(T_LORA_PAGER) && !defined(T_DECK)
if (isWifiAvailable()) {
/*
diff --git a/variants/esp32s3/heltec_v4/platformio.ini b/variants/esp32s3/heltec_v4/platformio.ini
index 5a5004a45..d6634aa74 100644
--- a/variants/esp32s3/heltec_v4/platformio.ini
+++ b/variants/esp32s3/heltec_v4/platformio.ini
@@ -120,7 +120,7 @@ build_flags =
-D TFT_OFFSET_Y=0
-D TFT_OFFSET_ROTATION=0
-D SCREEN_ROTATE
- -D SCREEN_TRANSITION_FRAMERATE=5
+ -D SCREEN_TRANSITION_FRAMERATE=30
-D BRIGHTNESS_DEFAULT=130 ; Medium Low Brightness
-D HAS_TOUCHSCREEN=1
-D TOUCH_I2C_PORT=0
@@ -133,4 +133,4 @@ lib_deps = ${heltec_v4_base.lib_deps}
# renovate: datasource=custom.pio depName=LovyanGFX packageName=lovyan03/library/LovyanGFX
lovyan03/LovyanGFX@1.2.19
# renovate: datasource=git-refs depName=Quency-D_chsc6x packageName=https://github.com/Quency-D/chsc6x gitBranch=master
- https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
\ No newline at end of file
+ https://github.com/Quency-D/chsc6x/archive/5cbead829d6b432a8d621ed1aafd4eb474fd4f27.zip
diff --git a/variants/esp32s3/heltec_vision_master_t190/platformio.ini b/variants/esp32s3/heltec_vision_master_t190/platformio.ini
index 3dab9f93c..c0c3b2f0e 100644
--- a/variants/esp32s3/heltec_vision_master_t190/platformio.ini
+++ b/variants/esp32s3/heltec_vision_master_t190/platformio.ini
@@ -20,5 +20,5 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
- https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
+ https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip
upload_speed = 921600
diff --git a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
index 69c4f52a5..1144994a0 100644
--- a/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
+++ b/variants/esp32s3/m5stack_cardputer_adv/platformio.ini
@@ -13,7 +13,7 @@ build_flags =
lib_deps =
${esp32s3_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
- https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
+ https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip
# renovate: datasource=github-tags depName=pschatzmann_arduino-audio-driver packageName=pschatzmann/arduino-audio-driver
https://github.com/pschatzmann/arduino-audio-driver/archive/v0.2.1.zip
# renovate: datasource=git-refs depName=ESP8266Audio packageName=https://github.com/meshtastic/ESP8266Audio gitBranch=meshtastic-2.0.0-dacfix
diff --git a/variants/esp32s3/picomputer-s3/variant.h b/variants/esp32s3/picomputer-s3/variant.h
index 7b6218f87..60afac002 100644
--- a/variants/esp32s3/picomputer-s3/variant.h
+++ b/variants/esp32s3/picomputer-s3/variant.h
@@ -47,10 +47,9 @@
#define TFT_OFFSET_Y 0
#define TFT_OFFSET_ROTATION 0
#define SCREEN_ROTATE
-#define SCREEN_TRANSITION_FRAMERATE 5
+#define SCREEN_TRANSITION_FRAMERATE 30
// Picomputer gets a white on black display
-#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255)
#define INPUTBROKER_MATRIX_TYPE 1
diff --git a/variants/esp32s3/seeed-sensecap-indicator/variant.h b/variants/esp32s3/seeed-sensecap-indicator/variant.h
index f946528ae..8fa9e2393 100644
--- a/variants/esp32s3/seeed-sensecap-indicator/variant.h
+++ b/variants/esp32s3/seeed-sensecap-indicator/variant.h
@@ -36,7 +36,7 @@
#define TFT_OFFSET_ROTATION 0
#define TFT_BL 45
#define SCREEN_ROTATE
-#define SCREEN_TRANSITION_FRAMERATE 5 // fps
+#define SCREEN_TRANSITION_FRAMERATE 30 // fps
#define USE_TFTDISPLAY 1
#define HAS_TOUCHSCREEN 1
diff --git a/variants/esp32s3/station-g2/pins_arduino.h b/variants/esp32s3/station-g2/pins_arduino.h
old mode 100644
new mode 100755
diff --git a/variants/esp32s3/station-g2/platformio.ini b/variants/esp32s3/station-g2/platformio.ini
old mode 100644
new mode 100755
diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h
old mode 100644
new mode 100755
diff --git a/variants/esp32s3/t-deck/variant.h b/variants/esp32s3/t-deck/variant.h
index 5d885579a..eb1bbdfef 100644
--- a/variants/esp32s3/t-deck/variant.h
+++ b/variants/esp32s3/t-deck/variant.h
@@ -20,7 +20,7 @@
#define TFT_OFFSET_Y 0
#define TFT_OFFSET_ROTATION 0
#define SCREEN_ROTATE
-#define SCREEN_TRANSITION_FRAMERATE 5
+#define SCREEN_TRANSITION_FRAMERATE 30
#define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness
#define USE_TFTDISPLAY 1
#define HAS_PHYSICAL_KEYBOARD 1
diff --git a/variants/esp32s3/tlora-pager/variant.h b/variants/esp32s3/tlora-pager/variant.h
index d97f864c3..3d6397475 100644
--- a/variants/esp32s3/tlora-pager/variant.h
+++ b/variants/esp32s3/tlora-pager/variant.h
@@ -18,7 +18,7 @@
#define TFT_OFFSET_Y 0
#define TFT_OFFSET_ROTATION 3
#define SCREEN_ROTATE
-#define SCREEN_TRANSITION_FRAMERATE 5
+#define SCREEN_TRANSITION_FRAMERATE 30
#define BRIGHTNESS_DEFAULT 130 // Medium Low Brightness
#define USE_TFTDISPLAY 1
#define HAS_PHYSICAL_KEYBOARD 1
diff --git a/variants/esp32s3/tracksenger/internal/variant.h b/variants/esp32s3/tracksenger/internal/variant.h
index f9a20c901..b2822c24b 100644
--- a/variants/esp32s3/tracksenger/internal/variant.h
+++ b/variants/esp32s3/tracksenger/internal/variant.h
@@ -73,7 +73,6 @@
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
// Picomputer gets a white on black display
-#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255)
// keyboard changes
@@ -89,4 +88,4 @@
{ \
26, 37, 17, 16, 15, 7 \
}
-// #end keyboard
\ No newline at end of file
+// #end keyboard
diff --git a/variants/esp32s3/tracksenger/lcd/variant.h b/variants/esp32s3/tracksenger/lcd/variant.h
index 029f7753b..6c32ff279 100644
--- a/variants/esp32s3/tracksenger/lcd/variant.h
+++ b/variants/esp32s3/tracksenger/lcd/variant.h
@@ -97,7 +97,6 @@
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
// Picomputer gets a white on black display
-#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255)
// keyboard changes
@@ -113,4 +112,4 @@
{ \
26, 37, 17, 16, 15, 7 \
}
-// #end keyboard
\ No newline at end of file
+// #end keyboard
diff --git a/variants/esp32s3/tracksenger/oled/variant.h b/variants/esp32s3/tracksenger/oled/variant.h
index 1f1fbbaa1..72762c7af 100644
--- a/variants/esp32s3/tracksenger/oled/variant.h
+++ b/variants/esp32s3/tracksenger/oled/variant.h
@@ -74,7 +74,6 @@
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
// Picomputer gets a white on black display
-#define TFT_MESH_OVERRIDE COLOR565(255, 255, 255)
// keyboard changes
@@ -90,4 +89,4 @@
{ \
26, 37, 17, 16, 15, 7 \
}
-// #end keyboard
\ No newline at end of file
+// #end keyboard
diff --git a/variants/esp32s3/unphone/variant.h b/variants/esp32s3/unphone/variant.h
index 268eedea5..76c66ca64 100644
--- a/variants/esp32s3/unphone/variant.h
+++ b/variants/esp32s3/unphone/variant.h
@@ -35,7 +35,7 @@
#define TFT_OFFSET_ROTATION 6 // unPhone's screen wired unusually, 0 typical
#define TFT_INVERT false
#define SCREEN_ROTATE true
-#define SCREEN_TRANSITION_FRAMERATE 5
+#define SCREEN_TRANSITION_FRAMERATE 30
#define USE_TFTDISPLAY 1
#define HAS_TOUCHSCREEN 1
@@ -74,4 +74,4 @@
// #define BATTERY_PIN 13 // battery V measurement pin; vbat divider is here
// #define ADC_CHANNEL ADC2_GPIO13_CHANNEL
-// #define BAT_MEASURE_ADC_UNIT 2
\ No newline at end of file
+// #define BAT_MEASURE_ADC_UNIT 2
diff --git a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini
index c9f998240..77beb4d33 100644
--- a/variants/nrf52840/heltec_mesh_node_t114/platformio.ini
+++ b/variants/nrf52840/heltec_mesh_node_t114/platformio.ini
@@ -23,4 +23,4 @@ build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_
lib_deps =
${nrf52840_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
- https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
+ https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip
diff --git a/variants/nrf52840/heltec_mesh_node_t114/variant.h b/variants/nrf52840/heltec_mesh_node_t114/variant.h
index e7385c4bb..509f749a8 100644
--- a/variants/nrf52840/heltec_mesh_node_t114/variant.h
+++ b/variants/nrf52840/heltec_mesh_node_t114/variant.h
@@ -57,9 +57,6 @@ extern "C" {
#define TFT_OFFSET_X 0
#define TFT_OFFSET_Y 0
-// T114 gets a muted yellow on black display
-#define TFT_MESH_OVERRIDE COLOR565(255, 255, 128)
-
// #define TFT_OFFSET_ROTATION 0
// #define SCREEN_ROTATE
// #define SCREEN_TRANSITION_FRAMERATE 5
diff --git a/variants/nrf52840/heltec_mesh_solar/platformio.ini b/variants/nrf52840/heltec_mesh_solar/platformio.ini
index 1b6f59a68..ae68455dc 100644
--- a/variants/nrf52840/heltec_mesh_solar/platformio.ini
+++ b/variants/nrf52840/heltec_mesh_solar/platformio.ini
@@ -132,4 +132,4 @@ build_flags = ${heltec_mesh_solar_base.build_flags}
lib_deps =
${heltec_mesh_solar_base.lib_deps}
# renovate: datasource=git-refs depName=meshtastic-st7789 packageName=https://github.com/meshtastic/st7789 gitBranch=main
- https://github.com/meshtastic/st7789/archive/92bae2e4a307afb430c3b0bc3d661c55ee1565f0.zip
+ https://github.com/meshtastic/st7789/archive/5180423ae2dbf5885168a8bfb308c7fb7eff6930.zip