From 8dde4eeee196df6f2141ce0e463f93f995af433a Mon Sep 17 00:00:00 2001 From: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:44:56 -0400 Subject: [PATCH] BaseUI: Color Support for TFT Nodes (#10233) * True Colors on TFT (Heltec Mesh Node T114, Heltec Vision Master T190, CardPuter Adv, T-Deck, T-Lora Pager) * Theme support - New and some Classic Themes! * Colored Compass --------- Co-authored-by: Jason P Co-authored-by: Jonathan Bennett Co-authored-by: Ben Meadors --- src/graphics/Screen.cpp | 112 +-- src/graphics/SharedUIDisplay.cpp | 320 +++++-- src/graphics/SharedUIDisplay.h | 4 +- src/graphics/TFTColorRegions.cpp | 819 ++++++++++++++++ src/graphics/TFTColorRegions.h | 163 ++++ src/graphics/TFTDisplay.cpp | 185 +++- src/graphics/TFTDisplay.h | 3 +- src/graphics/TFTPalette.h | 70 ++ src/graphics/draw/ClockRenderer.cpp | 10 +- src/graphics/draw/CompassRenderer.cpp | 120 +-- src/graphics/draw/CompassRenderer.h | 1 + src/graphics/draw/DebugRenderer.cpp | 35 +- src/graphics/draw/MenuHandler.cpp | 185 ++-- src/graphics/draw/MenuHandler.h | 19 +- src/graphics/draw/MessageRenderer.cpp | 144 ++- src/graphics/draw/NodeListRenderer.cpp | 57 ++ src/graphics/draw/NotificationRenderer.cpp | 36 +- src/graphics/draw/UIRenderer.cpp | 886 +++++++++++++----- src/graphics/draw/UIRenderer.h | 2 + src/motion/AccelerometerThread.h | 0 src/motion/BMA423Sensor.cpp | 0 src/motion/BMA423Sensor.h | 0 src/motion/BMX160Sensor.cpp | 0 src/motion/BMX160Sensor.h | 0 src/motion/ICM20948Sensor.cpp | 0 src/motion/ICM20948Sensor.h | 0 src/motion/LIS3DHSensor.cpp | 0 src/motion/LIS3DHSensor.h | 0 src/motion/LSM6DS3Sensor.cpp | 0 src/motion/LSM6DS3Sensor.h | 0 src/motion/MPU6050Sensor.cpp | 0 src/motion/MPU6050Sensor.h | 0 src/motion/MotionSensor.cpp | 0 src/motion/MotionSensor.h | 0 src/motion/STK8XXXSensor.cpp | 0 src/motion/STK8XXXSensor.h | 0 src/sleep.cpp | 2 +- variants/esp32s3/heltec_v4/platformio.ini | 4 +- .../heltec_vision_master_t190/platformio.ini | 2 +- .../m5stack_cardputer_adv/platformio.ini | 2 +- variants/esp32s3/picomputer-s3/variant.h | 3 +- .../seeed-sensecap-indicator/variant.h | 2 +- variants/esp32s3/station-g2/pins_arduino.h | 0 variants/esp32s3/station-g2/platformio.ini | 0 variants/esp32s3/station-g2/variant.h | 0 variants/esp32s3/t-deck/variant.h | 2 +- variants/esp32s3/tlora-pager/variant.h | 2 +- .../esp32s3/tracksenger/internal/variant.h | 3 +- variants/esp32s3/tracksenger/lcd/variant.h | 3 +- variants/esp32s3/tracksenger/oled/variant.h | 3 +- variants/esp32s3/unphone/variant.h | 4 +- .../heltec_mesh_node_t114/platformio.ini | 2 +- .../nrf52840/heltec_mesh_node_t114/variant.h | 3 - .../nrf52840/heltec_mesh_solar/platformio.ini | 2 +- 54 files changed, 2536 insertions(+), 674 deletions(-) create mode 100644 src/graphics/TFTColorRegions.cpp create mode 100644 src/graphics/TFTColorRegions.h create mode 100644 src/graphics/TFTPalette.h mode change 100644 => 100755 src/motion/AccelerometerThread.h mode change 100644 => 100755 src/motion/BMA423Sensor.cpp mode change 100644 => 100755 src/motion/BMA423Sensor.h mode change 100644 => 100755 src/motion/BMX160Sensor.cpp mode change 100644 => 100755 src/motion/BMX160Sensor.h mode change 100644 => 100755 src/motion/ICM20948Sensor.cpp mode change 100644 => 100755 src/motion/ICM20948Sensor.h mode change 100644 => 100755 src/motion/LIS3DHSensor.cpp mode change 100644 => 100755 src/motion/LIS3DHSensor.h mode change 100644 => 100755 src/motion/LSM6DS3Sensor.cpp mode change 100644 => 100755 src/motion/LSM6DS3Sensor.h mode change 100644 => 100755 src/motion/MPU6050Sensor.cpp mode change 100644 => 100755 src/motion/MPU6050Sensor.h mode change 100644 => 100755 src/motion/MotionSensor.cpp mode change 100644 => 100755 src/motion/MotionSensor.h mode change 100644 => 100755 src/motion/STK8XXXSensor.cpp mode change 100644 => 100755 src/motion/STK8XXXSensor.h mode change 100644 => 100755 variants/esp32s3/station-g2/pins_arduino.h mode change 100644 => 100755 variants/esp32s3/station-g2/platformio.ini mode change 100644 => 100755 variants/esp32s3/station-g2/variant.h 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