Merge branch 'master' into master

This commit is contained in:
Ben Meadors
2026-04-16 07:07:03 -05:00
committed by GitHub
805 changed files with 21556 additions and 5792 deletions
+12 -3
View File
@@ -1,6 +1,6 @@
#include "configuration.h"
#ifdef USE_EINK
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "EInkDisplay2.h"
#include "SPILock.h"
#include "main.h"
@@ -101,7 +101,7 @@ bool EInkDisplay::forceDisplay(uint32_t msecLimit)
return true;
}
// End the update process - virtual method, overriden in derived class
// End the update process - virtual method, overridden in derived class
void EInkDisplay::endUpdate()
{
#ifndef EINK_NOT_HIBERNATE
@@ -146,6 +146,10 @@ bool EInkDisplay::connect()
#ifdef ELECROW_ThinkNode_M1
// ThinkNode M1 has a hardware dimmable backlight. Start enabled
digitalWrite(PIN_EINK_EN, HIGH);
#elif defined(MINI_EPAPER_S3)
// T-Mini Epaper S3 requires panel power rail enabled before SPI transfer.
digitalWrite(PIN_EINK_EN, HIGH);
delay(10);
#else
digitalWrite(PIN_EINK_EN, LOW);
#endif
@@ -205,7 +209,8 @@ bool EInkDisplay::connect()
}
#elif defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || \
defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER)
defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || \
defined(MINI_EPAPER_S3)
{
// Start HSPI
hspi = new SPIClass(HSPI);
@@ -219,9 +224,13 @@ bool EInkDisplay::connect()
// Init GxEPD2
adafruitDisplay->init();
#if defined(MINI_EPAPER_S3)
adafruitDisplay->setRotation(3);
#else
adafruitDisplay->setRotation(3);
#if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(CROWPANEL_ESP32S3_4_EPAPER)
adafruitDisplay->setRotation(0);
#endif
#endif
}
#elif defined(PCA10059) || defined(ME25LS01)
+3 -2
View File
@@ -1,6 +1,6 @@
#pragma once
#ifdef USE_EINK
#if defined(USE_EINK) && !defined(USE_EINK_PARALLELDISPLAY)
#include "GxEPD2_BW.h"
#include <OLEDDisplay.h>
@@ -89,7 +89,8 @@ class EInkDisplay : public OLEDDisplay
// If display uses HSPI
#if defined(HELTEC_WIRELESS_PAPER) || defined(HELTEC_WIRELESS_PAPER_V1_0) || defined(HELTEC_VISION_MASTER_E213) || \
defined(HELTEC_VISION_MASTER_E290) || defined(TLORA_T3S3_EPAPER) || defined(CROWPANEL_ESP32S3_5_EPAPER) || \
defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5)
defined(CROWPANEL_ESP32S3_4_EPAPER) || defined(CROWPANEL_ESP32S3_2_EPAPER) || defined(ELECROW_ThinkNode_M5) || \
defined(MINI_EPAPER_S3)
SPIClass *hspi = NULL;
#endif
+6 -6
View File
@@ -10,7 +10,7 @@ EInkDynamicDisplay::EInkDynamicDisplay(uint8_t address, int sda, int scl, OLEDDI
{
// If tracking ghost pixels, grab memory
#ifdef EINK_LIMIT_GHOSTING_PX
dirtyPixels = new uint8_t[EInkDisplay::displayBufferSize](); // Init with zeros
dirtyPixels = std::unique_ptr<uint8_t[]>(new uint8_t[EInkDisplay::displayBufferSize]()); // Init with zeros
#endif
}
@@ -19,7 +19,7 @@ EInkDynamicDisplay::~EInkDynamicDisplay()
{
// If we were tracking ghost pixels, free the memory
#ifdef EINK_LIMIT_GHOSTING_PX
delete[] dirtyPixels;
dirtyPixels = nullptr;
#endif
}
@@ -95,7 +95,7 @@ void EInkDynamicDisplay::adjustRefreshCounters()
// Trigger the display update by calling base class
bool EInkDynamicDisplay::update()
{
// Detemine the refresh mode to use, and start the update
// Determine the refresh mode to use, and start the update
bool refreshApproved = determineMode();
if (refreshApproved) {
EInkDisplay::forceDisplay(0); // Bypass base class' own rate-limiting system
@@ -317,7 +317,7 @@ void EInkDynamicDisplay::checkFrameMatchesPrevious()
LOG_DEBUG("refresh=SKIPPED, reason=FRAME_MATCHED_PREVIOUS, frameFlags=0x%x", frameFlags);
}
// Have too many fast-refreshes occured consecutively, since last full refresh?
// Have too many fast-refreshes occurred consecutively, since last full refresh?
void EInkDynamicDisplay::checkConsecutiveFastRefreshes()
{
// If a decision was already reached, don't run the check
@@ -454,7 +454,7 @@ void EInkDynamicDisplay::checkExcessiveGhosting()
void EInkDynamicDisplay::resetGhostPixelTracking()
{
// Copy the current frame into dirtyPixels[] from the display buffer
memcpy(dirtyPixels, EInkDisplay::buffer, EInkDisplay::displayBufferSize);
memcpy(dirtyPixels.get(), EInkDisplay::buffer, EInkDisplay::displayBufferSize);
}
#endif // EINK_LIMIT_GHOSTING_PX
@@ -561,4 +561,4 @@ void EInkDynamicDisplay::awaitRefresh()
}
#endif // HAS_EINK_ASYNCFULL
#endif // USE_EINK_DYNAMICDISPLAY
#endif // USE_EINK_DYNAMICDISPLAY
+6 -5
View File
@@ -1,6 +1,7 @@
#pragma once
#include "configuration.h"
#include <memory>
#if defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
@@ -116,11 +117,11 @@ class EInkDynamicDisplay : public EInkDisplay, protected concurrency::NotifiedWo
// Optional - track ghosting, pixel by pixel
// May 2024: no longer used by any display. Kept for possible future use.
#ifdef EINK_LIMIT_GHOSTING_PX
void countGhostPixels(); // Count any pixels which have moved from black to white since last full-refresh
void checkExcessiveGhosting(); // Check if ghosting exceeds defined limit
void resetGhostPixelTracking(); // Clear the dirty pixels array. Call when full-refresh cleans the display.
uint8_t *dirtyPixels; // Any pixels that have been black since last full-refresh (dynamically allocated mem)
uint32_t ghostPixelCount = 0; // Number of pixels with problematic ghosting. Retained here for LOG_DEBUG use
void countGhostPixels(); // Count any pixels which have moved from black to white since last full-refresh
void checkExcessiveGhosting(); // Check if ghosting exceeds defined limit
void resetGhostPixelTracking(); // Clear the dirty pixels array. Call when full-refresh cleans the display.
std::unique_ptr<uint8_t[]> dirtyPixels; // Any pixels that have been black since last full-refresh (dynamically allocated mem)
uint32_t ghostPixelCount = 0; // Number of pixels with problematic ghosting. Retained here for LOG_DEBUG use
#endif
// Conditional - async full refresh - only with modified meshtastic/GxEPD2
+427
View File
@@ -0,0 +1,427 @@
#include "EInkParallelDisplay.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include "Wire.h"
#include "variant.h"
#include <Arduino.h>
#include <atomic>
#include <stdlib.h>
#include <string.h>
#include "FastEPD.h"
// Thresholds for choosing partial vs full update
#ifndef EPD_PARTIAL_THRESHOLD_ROWS
#define EPD_PARTIAL_THRESHOLD_ROWS 128 // if changed region <= this many rows, prefer partial
#endif
#ifndef EPD_FULLSLOW_PERIOD
#define EPD_FULLSLOW_PERIOD 100 // every N full updates do a slow (CLEAR_SLOW) full refresh
#endif
#ifndef EPD_RESPONSIVE_MIN_MS
#define EPD_RESPONSIVE_MIN_MS 1000 // simple rate-limit (ms) for responsive updates
#endif
EInkParallelDisplay::EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rot) : epaper(nullptr), rotation(rot)
{
LOG_INFO("init EInkParallelDisplay");
// Set dimensions in OLEDDisplay base class
this->geometry = GEOMETRY_RAWMODE;
this->displayWidth = width;
this->displayHeight = height;
// Round shortest side up to nearest byte, to prevent truncation causing an undersized buffer
uint16_t shortSide = min(width, height);
uint16_t longSide = max(width, height);
if (shortSide % 8 != 0)
shortSide = (shortSide | 7) + 1;
this->displayBufferSize = longSide * (shortSide / 8);
#ifdef EINK_LIMIT_GHOSTING_PX
// allocate dirty pixel buffer same size as epaper buffers (rowBytes * height)
size_t rowBytes = (this->displayWidth + 7) / 8;
dirtyPixelsSize = rowBytes * this->displayHeight;
dirtyPixels = (uint8_t *)calloc(dirtyPixelsSize, 1);
ghostPixelCount = 0;
#endif
}
EInkParallelDisplay::~EInkParallelDisplay()
{
#ifdef EINK_LIMIT_GHOSTING_PX
if (dirtyPixels) {
free(dirtyPixels);
dirtyPixels = nullptr;
}
#endif
// If an async full update is running, wait for it to finish
if (asyncFullRunning.load()) {
// wait a short while for task to finish
for (int i = 0; i < 50 && asyncFullRunning.load(); ++i) {
delay(50);
}
if (asyncTaskHandle) {
// Let it finish or delete it
vTaskDelete(asyncTaskHandle);
asyncTaskHandle = nullptr;
}
}
delete epaper;
}
/*
* Called by the OLEDDisplay::init() path.
*/
bool EInkParallelDisplay::connect()
{
LOG_INFO("Do EPD init");
if (!epaper) {
epaper = new FASTEPD;
#if defined(T5_S3_EPAPER_PRO_V1)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO, 28000000);
#elif defined(T5_S3_EPAPER_PRO_V2)
epaper->initPanel(BB_PANEL_LILYGO_T5PRO_V2, 28000000);
// initialize all port 0 pins (0-7) as outputs / HIGH
for (int i = 0; i < 8; i++) {
epaper->ioPinMode(i, OUTPUT);
epaper->ioWrite(i, HIGH);
}
#else
#error "unsupported EPD device!"
#endif
}
// epaper->setRotation(rotation); // does not work, messes up width/height
epaper->setMode(BB_MODE_1BPP);
epaper->clearWhite();
epaper->fullUpdate(true);
#ifdef EINK_LIMIT_GHOSTING_PX
// After a full/clear the dirty tracking should be reset
resetGhostPixelTracking();
#endif
return true;
}
/*
* sendCommand - simple passthrough (not required for epd_driver-based path)
*/
void EInkParallelDisplay::sendCommand(uint8_t com)
{
LOG_DEBUG("EInkParallelDisplay::sendCommand %d", (int)com);
}
/*
* Start a background task that will perform a blocking fullUpdate(). This lets
* display() return quickly while the heavy refresh runs in the background.
*/
void EInkParallelDisplay::startAsyncFullUpdate(int clearMode)
{
if (asyncFullRunning.load())
return; // already running
asyncFullRunning.store(true);
// pass 'this' as parameter
BaseType_t rc = xTaskCreatePinnedToCore(EInkParallelDisplay::asyncFullUpdateTask, "epd_full", 4096 / sizeof(StackType_t),
this, 2, &asyncTaskHandle,
#if CONFIG_FREERTOS_UNICORE
0
#else
1
#endif
);
if (rc != pdPASS) {
LOG_WARN("Failed to create async full-update task, falling back to blocking update");
epaper->fullUpdate(clearMode, false);
epaper->backupPlane();
asyncFullRunning.store(false);
asyncTaskHandle = nullptr;
}
}
/*
* FreeRTOS task entry: runs the full update and then backs up plane.
*/
void EInkParallelDisplay::asyncFullUpdateTask(void *pvParameters)
{
EInkParallelDisplay *self = static_cast<EInkParallelDisplay *>(pvParameters);
if (!self) {
vTaskDelete(nullptr);
return;
}
// choose CLEAR_SLOW occasionally
int clearMode = CLEAR_FAST;
if (self->fastRefreshCount >= EPD_FULLSLOW_PERIOD) {
clearMode = CLEAR_SLOW;
self->fastRefreshCount = 0;
} else {
// when running async full, treat it as a full so reset fast count
self->fastRefreshCount = 0;
}
self->epaper->fullUpdate(clearMode, false);
self->epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
// A full refresh clears ghosting state
self->resetGhostPixelTracking();
#endif
self->asyncFullRunning.store(false);
self->asyncTaskHandle = nullptr;
// delete this task
vTaskDelete(nullptr);
}
/*
* Convert the OLEDDisplay buffer (vertical byte layout) into the 1bpp horizontal-bytes
* buffer used by the FASTEPD library. For performance we write directly into FASTEPD's
* currentBuffer() while comparing against previousBuffer() to detect changed rows.
* After conversion we call FASTEPD::partialUpdate() or FASTEPD::fullUpdate() according
* to a heuristic so only the minimal region is refreshed.
*/
void EInkParallelDisplay::display(void)
{
const uint16_t w = this->displayWidth;
const uint16_t h = this->displayHeight;
// Simple rate limiting: avoid very-frequent responsive updates
uint32_t nowMs = millis();
if (lastUpdateMs != 0 && (nowMs - lastUpdateMs) < EPD_RESPONSIVE_MIN_MS) {
LOG_DEBUG("rate-limited, skipping update");
return;
}
// bytes per row in epd format (one byte = 8 horizontal pixels)
const uint32_t rowBytes = (w + 7) / 8;
// Get pointers to internal buffers
uint8_t *cur = epaper->currentBuffer();
const uint8_t *prev = epaper->previousBuffer(); // may be NULL on first init
// Track changed row range while converting
int newTop = h; // min changed row (initialized to out-of-range)
int newBottom = -1; // max changed row
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Track changed byte column range (for clipped fullUpdate fallback)
int newLeftByte = (int)rowBytes;
int newRightByte = -1;
#endif
// Compute a quick hash of the incoming OLED buffer (so we can skip identical frames)
uint32_t imageHash = 0;
uint32_t bufBytes = (w / 8) * h; // vertical-byte layout size
for (uint32_t bi = 0; bi < bufBytes; ++bi) {
imageHash ^= ((uint32_t)buffer[bi]) << (bi & 31);
}
if (imageHash == previousImageHash) {
// LOG_DEBUG("image identical to previous, skipping update");
return;
}
#ifdef EINK_LIMIT_GHOSTING_PX
// reset ghost count for this conversion pass; we'll mark bits that change
ghostPixelCount = 0;
#endif
// Convert: OLED buffer layout -> FASTEPD 1bpp horizontal-bytes layout into cur,
// comparing against prev when available to detect changes.
for (uint32_t y = 0; y < h; ++y) {
const uint32_t base = (y >> 3) * w; // (y/8) * width
const uint8_t bitMask = (uint8_t)(1u << (y & 7)); // mask for this row in vertical-byte layout
const uint32_t rowBase = y * rowBytes;
// process full 8-pixel bytes
for (uint32_t xb = 0; xb < rowBytes; ++xb) {
uint32_t x0 = xb * 8;
// read up to 8 source bytes (vertical-byte per column)
uint8_t b0 = (x0 + 0 < w) ? buffer[base + x0 + 0] : 0;
uint8_t b1 = (x0 + 1 < w) ? buffer[base + x0 + 1] : 0;
uint8_t b2 = (x0 + 2 < w) ? buffer[base + x0 + 2] : 0;
uint8_t b3 = (x0 + 3 < w) ? buffer[base + x0 + 3] : 0;
uint8_t b4 = (x0 + 4 < w) ? buffer[base + x0 + 4] : 0;
uint8_t b5 = (x0 + 5 < w) ? buffer[base + x0 + 5] : 0;
uint8_t b6 = (x0 + 6 < w) ? buffer[base + x0 + 6] : 0;
uint8_t b7 = (x0 + 7 < w) ? buffer[base + x0 + 7] : 0;
// build output byte: MSB = leftmost pixel
uint8_t out = 0;
out |= (uint8_t)((b0 & bitMask) ? 0x80 : 0x00);
out |= (uint8_t)((b1 & bitMask) ? 0x40 : 0x00);
out |= (uint8_t)((b2 & bitMask) ? 0x20 : 0x00);
out |= (uint8_t)((b3 & bitMask) ? 0x10 : 0x00);
out |= (uint8_t)((b4 & bitMask) ? 0x08 : 0x00);
out |= (uint8_t)((b5 & bitMask) ? 0x04 : 0x00);
out |= (uint8_t)((b6 & bitMask) ? 0x02 : 0x00);
out |= (uint8_t)((b7 & bitMask) ? 0x01 : 0x00);
// handle partial byte at end of row by masking off invalid bits
uint8_t mask = 0xFF;
uint32_t bitsRemain = (w > x0) ? (w - x0) : 0;
if (bitsRemain > 0 && bitsRemain < 8) {
mask = (uint8_t)(0xFF << (8 - bitsRemain));
out &= mask;
}
// invert to FASTEPD polarity
out = (~out) & mask;
uint32_t pos = rowBase + xb;
uint8_t prevVal = prev ? (prev[pos] & mask) : 0x00;
// Consider this byte changed if previous buffer differs (or prev is null)
bool changed = (prev == nullptr) || (prevVal != out);
#ifdef EINK_LIMIT_GHOSTING_PX
if (changed && prev)
markDirtyBits(prev, pos, mask, out);
#endif
// mark row changed only if the previous buffer differs
if (changed) {
if (y < (uint32_t)newTop)
newTop = y;
if ((int)y > newBottom)
newBottom = y;
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// record changed column bytes
if ((int)xb < newLeftByte)
newLeftByte = (int)xb;
if ((int)xb > newRightByte)
newRightByte = (int)xb;
#endif
}
// Always write the computed value into the current buffer (avoid leaving stale bytes)
cur[pos] = (cur[pos] & ~mask) | out;
}
}
// If nothing changed, avoid any panel update
if (newBottom < 0) {
LOG_DEBUG("no pixel changes detected, skipping update (conv)");
previousImageHash = imageHash; // still remember that frame
return;
}
// Choose partial vs full update using heuristic
// Decide if we should force a full update after many fast updates
bool forceFull = (fastRefreshCount >= EPD_FULLSLOW_PERIOD);
#ifdef EINK_LIMIT_GHOSTING_PX
// If ghost pixels exceed limit, force a full update to clear ghosting
if (ghostPixelCount > ghostPixelLimit) {
LOG_WARN("ghost pixels %u > limit %u, forcing full refresh", ghostPixelCount, ghostPixelLimit);
forceFull = true;
}
#endif
// Compute pixel bounds from newTop/newBottom
int startRow = (newTop / 8) * 8;
int endRow = (newBottom / 8) * 8 + 7;
LOG_DEBUG("EPD update rows=%d..%d alignedRows=%d..%d rowBytes=%u", newTop, newBottom, startRow, endRow, rowBytes);
if (epaper->getMode() == BB_MODE_1BPP && !forceFull && (newBottom - newTop) <= EPD_PARTIAL_THRESHOLD_ROWS) {
// Prefer partial update path if driver is reliable; otherwise use clipped fullUpdate fallback.
#ifdef FAST_EPD_PARTIAL_UPDATE_BUG
// Workaround for FastEPD partial update bug: use clipped fullUpdate instead
// Build a pixel rectangle for a clipped fullUpdate using the changed columns
int startCol = (newLeftByte <= newRightByte) ? (newLeftByte * 8) : 0;
int endCol = (newLeftByte <= newRightByte) ? ((newRightByte + 1) * 8 - 1) : (w - 1);
BB_RECT rect{startCol, startRow, endCol - startCol + 1, endRow - startRow + 1};
// LOG_DEBUG("Using clipped fullUpdate rect x=%d y=%d w=%d h=%d", rect.x, rect.y, rect.w, rect.h);
epaper->fullUpdate(CLEAR_FAST, false, &rect);
#else
// Use rows for partial update
LOG_DEBUG("calling partialUpdate startRow=%d endRow=%d", startRow, endRow);
epaper->partialUpdate(true, startRow, endRow);
#endif
epaper->backupPlane();
fastRefreshCount++;
} else {
// Full update: run async if possible (startAsyncFullUpdate will fall back to blocking)
startAsyncFullUpdate(forceFull ? CLEAR_SLOW : CLEAR_FAST);
}
lastUpdateMs = millis();
previousImageHash = imageHash;
// Keep same behavior as before
lastDrawMsec = millis();
}
#ifdef EINK_LIMIT_GHOSTING_PX
// markDirtyBits: mark per-bit dirty flags and update ghostPixelCount
void EInkParallelDisplay::markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out)
{
// defensive: need dirtyPixels allocated and prevBuf valid
if (!dirtyPixels || !prevBuf)
return;
// 'out' is in FASTEPD polarity (1 = black, 0 = white)
uint8_t newBlack = out & mask; // bits that will be black now
uint8_t newWhite = (~out) & mask; // bits that will be white now
// previously recorded dirty bits for this byte
uint8_t before = dirtyPixels[pos];
// Ghost bits: bits that were previously marked dirty and are now being driven white
uint8_t ghostBits = before & newWhite;
if (ghostBits) {
ghostPixelCount += __builtin_popcount((unsigned)ghostBits);
}
// Only mark bits dirty when they turn black now (accumulate until a full refresh)
uint8_t newlyDirty = newBlack & (~before);
if (newlyDirty) {
dirtyPixels[pos] |= newlyDirty;
}
}
// reset ghost tracking (call after a full refresh)
void EInkParallelDisplay::resetGhostPixelTracking()
{
if (!dirtyPixels)
return;
memset(dirtyPixels, 0, dirtyPixelsSize);
ghostPixelCount = 0;
}
#endif
/*
* forceDisplay: use lastDrawMsec
*/
bool EInkParallelDisplay::forceDisplay(uint32_t msecLimit)
{
uint32_t now = millis();
if (lastDrawMsec == 0 || (now - lastDrawMsec) > msecLimit) {
display();
return true;
}
return false;
}
void EInkParallelDisplay::endUpdate()
{
{
// ensure any async full update is started/completed
if (asyncFullRunning.load()) {
// nothing to do; background task will run and call backupPlane when done
} else {
epaper->fullUpdate(CLEAR_FAST, false);
epaper->backupPlane();
#ifdef EINK_LIMIT_GHOSTING_PX
resetGhostPixelTracking();
#endif
}
}
}
#endif
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include "configuration.h"
#ifdef USE_EINK_PARALLELDISPLAY
#include <OLEDDisplay.h>
#include <atomic>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
class FASTEPD;
/**
* Adapter for E-Ink 8-bit parallel displays (EPD), specifically devices supported by FastEPD library
*/
class EInkParallelDisplay : public OLEDDisplay
{
public:
enum EpdRotation {
EPD_ROT_LANDSCAPE = 0,
EPD_ROT_PORTRAIT = 90,
EPD_ROT_INVERTED_LANDSCAPE = 180,
EPD_ROT_INVERTED_PORTRAIT = 270,
};
EInkParallelDisplay(uint16_t width, uint16_t height, EpdRotation rotation);
virtual ~EInkParallelDisplay();
// OLEDDisplay virtuals
bool connect() override;
void sendCommand(uint8_t com) override;
int getBufferOffset(void) override { return 0; }
void display(void) override;
bool forceDisplay(uint32_t msecLimit = 1000);
void endUpdate();
protected:
uint32_t lastDrawMsec = 0;
FASTEPD *epaper;
private:
// Async full-refresh support
std::atomic<bool> asyncFullRunning{false};
TaskHandle_t asyncTaskHandle = nullptr;
void startAsyncFullUpdate(int clearMode);
static void asyncFullUpdateTask(void *pvParameters);
#ifdef EINK_LIMIT_GHOSTING_PX
// helpers
void resetGhostPixelTracking();
void markDirtyBits(const uint8_t *prevBuf, uint32_t pos, uint8_t mask, uint8_t out);
void countGhostPixelsAndMaybePromote(int &newTop, int &newBottom, bool &forceFull);
// per-bit dirty buffer (same format as epaper buffers): one bit == one pixel
uint8_t *dirtyPixels = nullptr;
size_t dirtyPixelsSize = 0;
uint32_t ghostPixelCount = 0;
uint32_t ghostPixelLimit = EINK_LIMIT_GHOSTING_PX;
#endif
EpdRotation rotation;
uint32_t previousImageHash = 0;
uint32_t lastUpdateMs = 0;
int fastRefreshCount = 0;
};
#endif
+434
View File
@@ -0,0 +1,434 @@
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/EmoteRenderer.h"
#include <algorithm>
#include <cstring>
namespace graphics
{
namespace EmoteRenderer
{
static inline int getStringWidth(OLEDDisplay *display, const char *text, size_t len)
{
#if defined(OLED_UA) || defined(OLED_RU)
return display->getStringWidth(text, len, true);
#else
(void)len;
return display->getStringWidth(text);
#endif
}
size_t utf8CharLen(uint8_t c)
{
if ((c & 0xE0) == 0xC0)
return 2;
if ((c & 0xF0) == 0xE0)
return 3;
if ((c & 0xF8) == 0xF0)
return 4;
return 1;
}
static inline bool isPossibleEmoteLead(uint8_t c)
{
// All supported emoji labels in emotes.cpp are currently in these UTF-8 lead ranges.
return c == 0xE2 || c == 0xF0;
}
static inline int getUtf8ChunkWidth(OLEDDisplay *display, const char *text, size_t len)
{
char chunk[5] = {0, 0, 0, 0, 0};
if (len > 4)
len = 4;
memcpy(chunk, text, len);
return getStringWidth(display, chunk, len);
}
static inline bool isFE0FAt(const char *s, size_t pos, size_t len)
{
return pos + 2 < len && static_cast<uint8_t>(s[pos]) == 0xEF && static_cast<uint8_t>(s[pos + 1]) == 0xB8 &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F;
}
static inline bool isSkinToneAt(const char *s, size_t pos, size_t len)
{
return pos + 3 < len && static_cast<uint8_t>(s[pos]) == 0xF0 && static_cast<uint8_t>(s[pos + 1]) == 0x9F &&
static_cast<uint8_t>(s[pos + 2]) == 0x8F &&
(static_cast<uint8_t>(s[pos + 3]) >= 0xBB && static_cast<uint8_t>(s[pos + 3]) <= 0xBF);
}
static inline size_t ignorableModifierLenAt(const char *s, size_t pos, size_t len)
{
// Skip modifiers that do not change which bitmap we render.
if (isFE0FAt(s, pos, len))
return 3;
if (isSkinToneAt(s, pos, len))
return 4;
return 0;
}
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet, int emoteCount)
{
if (!label || !*label)
return nullptr;
for (int i = 0; i < emoteCount; ++i) {
if (emoteSet[i].label && strcmp(label, emoteSet[i].label) == 0)
return &emoteSet[i];
}
return nullptr;
}
static bool matchAtIgnoringModifiers(const char *text, size_t textLen, size_t pos, const char *label, size_t &textConsumed,
size_t &matchScore)
{
// Treat FE0F and skin-tone modifiers as optional while matching.
textConsumed = 0;
matchScore = 0;
if (!label || !*label || pos >= textLen)
return false;
const size_t labelLen = strlen(label);
size_t ti = pos;
size_t li = 0;
while (true) {
while (ti < textLen) {
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
if (!skipLen)
break;
ti += skipLen;
}
while (li < labelLen) {
const size_t skipLen = ignorableModifierLenAt(label, li, labelLen);
if (!skipLen)
break;
li += skipLen;
}
if (li >= labelLen) {
while (ti < textLen) {
const size_t skipLen = ignorableModifierLenAt(text, ti, textLen);
if (!skipLen)
break;
ti += skipLen;
}
textConsumed = ti - pos;
return textConsumed > 0;
}
if (ti >= textLen)
return false;
const uint8_t tc = static_cast<uint8_t>(text[ti]);
const uint8_t lc = static_cast<uint8_t>(label[li]);
const size_t tlen = utf8CharLen(tc);
const size_t llen = utf8CharLen(lc);
if (tlen != llen || ti + tlen > textLen || li + llen > labelLen)
return false;
if (memcmp(text + ti, label + li, tlen) != 0)
return false;
ti += tlen;
li += llen;
matchScore += llen;
}
}
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet, int emoteCount)
{
// Prefer the longest matching label at this byte offset.
const Emote *matched = nullptr;
matchLen = 0;
size_t bestScore = 0;
if (!text || pos >= textLen)
return nullptr;
if (!isPossibleEmoteLead(static_cast<uint8_t>(text[pos])))
return nullptr;
for (int i = 0; i < emoteCount; ++i) {
const char *label = emoteSet[i].label;
if (!label || !*label)
continue;
if (static_cast<uint8_t>(label[0]) != static_cast<uint8_t>(text[pos]))
continue;
const size_t labelLen = strlen(label);
if (labelLen == 0)
continue;
size_t candidateLen = 0;
size_t candidateScore = 0;
if (pos + labelLen <= textLen && memcmp(text + pos, label, labelLen) == 0) {
candidateLen = labelLen;
candidateScore = labelLen;
} else if (matchAtIgnoringModifiers(text, textLen, pos, label, candidateLen, candidateScore)) {
// Matched with FE0F/skin tone modifiers treated as optional.
} else {
continue;
}
if (candidateScore > bestScore) {
matched = &emoteSet[i];
matchLen = candidateLen;
bestScore = candidateScore;
}
}
return matched;
}
static LineMetrics analyzeLineInternal(OLEDDisplay *display, const char *line, size_t lineLen, int fallbackHeight,
const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
// Scan once to collect width and tallest emote for this line.
LineMetrics metrics{0, fallbackHeight, false};
if (!line)
return metrics;
for (size_t i = 0; i < lineLen;) {
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
metrics.hasEmote = true;
metrics.tallestHeight = std::max(metrics.tallestHeight, matched->height);
if (display)
metrics.width += matched->width + emoteSpacing;
i += matchLen;
continue;
}
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
continue;
}
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
if (display)
metrics.width += getUtf8ChunkWidth(display, line + i, charLen);
i += charLen;
}
return metrics;
}
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight, const Emote *emoteSet, int emoteCount,
int emoteSpacing)
{
return analyzeLineInternal(display, line, line ? strlen(line) : 0, fallbackHeight, emoteSet, emoteCount, emoteSpacing);
}
int maxEmoteHeight(const Emote *emoteSet, int emoteCount)
{
int tallest = 0;
for (int i = 0; i < emoteCount; ++i) {
if (emoteSet[i].label && *emoteSet[i].label)
tallest = std::max(tallest, emoteSet[i].height);
}
return tallest;
}
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
if (!display)
return 0;
if (!line || !*line)
return 0;
return analyzeLine(display, line, 0, emoteSet, emoteCount, emoteSpacing).width;
}
static int appendTextSpanAndMeasure(OLEDDisplay *display, int cursorX, int fontY, const char *text, size_t len, bool draw,
bool fauxBold)
{
// Draw plain-text runs in chunks so UTF-8 stays intact.
if (!text || len == 0)
return cursorX;
char chunk[33];
size_t pos = 0;
while (pos < len) {
size_t chunkLen = 0;
while (pos + chunkLen < len) {
const size_t charLen = utf8CharLen(static_cast<uint8_t>(text[pos + chunkLen]));
if (chunkLen + charLen >= sizeof(chunk))
break;
chunkLen += charLen;
}
if (chunkLen == 0) {
chunkLen = std::min(len - pos, sizeof(chunk) - 1);
}
memcpy(chunk, text + pos, chunkLen);
chunk[chunkLen] = '\0';
if (draw) {
if (fauxBold)
display->drawString(cursorX + 1, fontY, chunk);
display->drawString(cursorX, fontY, chunk);
}
cursorX += getStringWidth(display, chunk, chunkLen);
pos += chunkLen;
}
return cursorX;
}
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth, const char *ellipsis,
const Emote *emoteSet, int emoteCount, int emoteSpacing)
{
if (!out || outSize == 0)
return 0;
out[0] = '\0';
if (!display || !line || maxWidth <= 0)
return 0;
const size_t lineLen = strlen(line);
const int suffixWidth =
(ellipsis && *ellipsis) ? measureStringWithEmotes(display, ellipsis, emoteSet, emoteCount, emoteSpacing) : 0;
const char *suffix = (ellipsis && suffixWidth <= maxWidth) ? ellipsis : "";
const size_t suffixLen = strlen(suffix);
const int availableWidth = maxWidth - (*suffix ? suffixWidth : 0);
if (measureStringWithEmotes(display, line, emoteSet, emoteCount, emoteSpacing) <= maxWidth) {
strncpy(out, line, outSize - 1);
out[outSize - 1] = '\0';
return strlen(out);
}
int used = 0;
size_t cut = 0;
for (size_t i = 0; i < lineLen;) {
// Keep whole emotes together when deciding where to cut.
int tokenWidth = 0;
size_t advance = 0;
if (isPossibleEmoteLead(static_cast<uint8_t>(line[i]))) {
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
tokenWidth = matched->width + emoteSpacing;
advance = matchLen;
}
}
if (advance == 0) {
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
cut = i;
continue;
}
const size_t charLen = utf8CharLen(static_cast<uint8_t>(line[i]));
tokenWidth = getUtf8ChunkWidth(display, line + i, charLen);
advance = charLen;
}
if (used + tokenWidth > availableWidth)
break;
used += tokenWidth;
i += advance;
cut = i;
}
if (cut == 0) {
strncpy(out, suffix, outSize - 1);
out[outSize - 1] = '\0';
return strlen(out);
}
size_t copyLen = cut;
if (copyLen > outSize - 1)
copyLen = outSize - 1;
if (suffixLen > 0 && copyLen + suffixLen > outSize - 1) {
copyLen = (outSize - 1 > suffixLen) ? (outSize - 1 - suffixLen) : 0;
}
memcpy(out, line, copyLen);
size_t totalLen = copyLen;
if (suffixLen > 0 && totalLen < outSize - 1) {
memcpy(out + totalLen, suffix, std::min(suffixLen, outSize - 1 - totalLen));
totalLen += std::min(suffixLen, outSize - 1 - totalLen);
}
out[totalLen] = '\0';
return totalLen;
}
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet,
int emoteCount, int emoteSpacing, bool fauxBold)
{
if (!line)
return;
const size_t lineLen = strlen(line);
// Center text vertically when any emote is taller than the font.
const int maxIconHeight =
analyzeLineInternal(nullptr, line, lineLen, fontHeight, emoteSet, emoteCount, emoteSpacing).tallestHeight;
const int lineHeight = std::max(fontHeight, maxIconHeight);
const int fontY = y + (lineHeight - fontHeight) / 2;
int cursorX = x;
bool inBold = false;
for (size_t i = 0; i < lineLen;) {
// Toggle faux bold.
if (fauxBold && i + 1 < lineLen && line[i] == '*' && line[i + 1] == '*') {
inBold = !inBold;
i += 2;
continue;
}
const size_t skipLen = ignorableModifierLenAt(line, i, lineLen);
if (skipLen) {
i += skipLen;
continue;
}
size_t matchLen = 0;
const Emote *matched = findEmoteAt(line, lineLen, i, matchLen, emoteSet, emoteCount);
if (matched) {
const int iconY = y + (lineHeight - matched->height) / 2;
display->drawXbm(cursorX, iconY, matched->width, matched->height, matched->bitmap);
cursorX += matched->width + emoteSpacing;
i += matchLen;
continue;
}
size_t next = i;
while (next < lineLen) {
// Stop the text run before the next emote or bold marker.
if (fauxBold && next + 1 < lineLen && line[next] == '*' && line[next + 1] == '*')
break;
if (ignorableModifierLenAt(line, next, lineLen))
break;
size_t nextMatchLen = 0;
if (findEmoteAt(line, lineLen, next, nextMatchLen, emoteSet, emoteCount) != nullptr)
break;
next += utf8CharLen(static_cast<uint8_t>(line[next]));
}
if (next == i)
next += utf8CharLen(static_cast<uint8_t>(line[i]));
cursorX = appendTextSpanAndMeasure(display, cursorX, fontY, line + i, next - i, true, fauxBold && inBold);
i = next;
}
}
} // namespace EmoteRenderer
} // namespace graphics
#endif // HAS_SCREEN
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN
#include "graphics/emotes.h"
#include <Arduino.h>
#include <OLEDDisplay.h>
#include <string>
#include <vector>
namespace graphics
{
namespace EmoteRenderer
{
struct LineMetrics {
int width;
int tallestHeight;
bool hasEmote;
};
size_t utf8CharLen(uint8_t c);
const Emote *findEmoteByLabel(const char *label, const Emote *emoteSet = emotes, int emoteCount = numEmotes);
const Emote *findEmoteAt(const char *text, size_t textLen, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
int emoteCount = numEmotes);
inline const Emote *findEmoteAt(const std::string &text, size_t pos, size_t &matchLen, const Emote *emoteSet = emotes,
int emoteCount = numEmotes)
{
return findEmoteAt(text.c_str(), text.length(), pos, matchLen, emoteSet, emoteCount);
}
LineMetrics analyzeLine(OLEDDisplay *display, const char *line, int fallbackHeight = 0, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1);
inline LineMetrics analyzeLine(OLEDDisplay *display, const std::string &line, int fallbackHeight = 0,
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1)
{
return analyzeLine(display, line.c_str(), fallbackHeight, emoteSet, emoteCount, emoteSpacing);
}
int maxEmoteHeight(const Emote *emoteSet = emotes, int emoteCount = numEmotes);
int measureStringWithEmotes(OLEDDisplay *display, const char *line, const Emote *emoteSet = emotes, int emoteCount = numEmotes,
int emoteSpacing = 1);
inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1)
{
return measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing);
}
size_t truncateToWidth(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis = "...", const Emote *emoteSet = emotes, int emoteCount = numEmotes,
int emoteSpacing = 1);
inline std::string truncateToWidth(OLEDDisplay *display, const std::string &line, int maxWidth,
const std::string &ellipsis = "...", const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1)
{
if (!display || maxWidth <= 0)
return "";
if (measureStringWithEmotes(display, line.c_str(), emoteSet, emoteCount, emoteSpacing) <= maxWidth)
return line;
std::vector<char> out(line.length() + ellipsis.length() + 1, '\0');
truncateToWidth(display, line.c_str(), out.data(), out.size(), maxWidth, ellipsis.c_str(), emoteSet, emoteCount,
emoteSpacing);
return std::string(out.data());
}
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, const Emote *emoteSet = emotes,
int emoteCount = numEmotes, int emoteSpacing = 1, bool fauxBold = true);
inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
const Emote *emoteSet = emotes, int emoteCount = numEmotes, int emoteSpacing = 1,
bool fauxBold = true)
{
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSet, emoteCount, emoteSpacing, fauxBold);
}
} // namespace EmoteRenderer
} // namespace graphics
#endif // HAS_SCREEN
-4
View File
@@ -1,4 +0,0 @@
#ifdef HAS_NEOPIXEL
#include <Adafruit_NeoPixel.h>
extern Adafruit_NeoPixel pixels;
#endif
+2
View File
@@ -1,4 +1,6 @@
#ifdef HAS_LP5562
#include <Wire.h>
#include <LP5562.h>
extern LP5562 rgbw;
-5
View File
@@ -1,5 +0,0 @@
#ifdef HAS_NCP5623
#include <NCP5623.h>
extern NCP5623 rgb;
#endif
+47 -5
View File
@@ -27,6 +27,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
#include "configuration.h"
#include "meshUtils.h"
#if HAS_SCREEN
#include "EInkParallelDisplay.h"
#include <OLEDDisplay.h>
#include "DisplayFormatters.h"
@@ -364,12 +365,14 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
defined(RAK14014) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || defined(HACKADAY_COMMUNICATOR)
dispdev = new TFTDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY) && !defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK) && defined(USE_EINK_DYNAMICDISPLAY)
dispdev = new EInkDynamicDisplay(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
#elif defined(USE_EINK_PARALLELDISPLAY)
dispdev = new EInkParallelDisplay(EPD_WIDTH, EPD_HEIGHT, EInkParallelDisplay::EPD_ROT_PORTRAIT);
#elif defined(USE_ST7567)
dispdev = new ST7567Wire(address.address, -1, -1, geometry,
(address.port == ScanI2C::I2CPort::WIRE1) ? HW_I2C::I2C_TWO : HW_I2C::I2C_ONE);
@@ -433,12 +436,15 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
PMU->enablePowerOutput(XPOWERS_ALDO2);
#endif
#if defined(MUZI_BASE)
// some screens seem to need a kick in the pants to turn back on
#if defined(MUZI_BASE) || defined(M5STACK_CARDPUTER_ADV)
dispdev->init();
dispdev->setBrightness(brightness);
dispdev->flipScreenVertically();
dispdev->resetDisplay();
#ifdef SCREEN_12V_ENABLE
digitalWrite(SCREEN_12V_ENABLE, HIGH);
#endif
delay(100);
#endif
#if !ARCH_PORTDUINO
@@ -462,9 +468,11 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
#if defined(HELTEC_TRACKER_V1_X) || defined(HELTEC_WIRELESS_TRACKER_V2)
ui->init();
#endif
#ifdef USE_ST7789
#if defined(USE_ST7789) && defined(VTFT_LEDA)
#ifdef VTFT_CTRL
pinMode(VTFT_CTRL, OUTPUT);
digitalWrite(VTFT_CTRL, LOW);
#endif
ui->init();
#ifdef ESP_PLATFORM
analogWrite(VTFT_LEDA, BRIGHTNESS_DEFAULT);
@@ -506,8 +514,12 @@ void Screen::handleSetOn(bool on, FrameCallback einkScreensaver)
#ifdef USE_ST7789
SPI1.end();
#if defined(ARCH_ESP32)
#ifdef VTFT_LEDA
pinMode(VTFT_LEDA, ANALOG);
#endif
#ifdef VTFT_CTRL
pinMode(VTFT_CTRL, ANALOG);
#endif
pinMode(ST7789_RESET, ANALOG);
pinMode(ST7789_RS, ANALOG);
pinMode(ST7789_NSS, ANALOG);
@@ -750,7 +762,11 @@ void Screen::forceDisplay(bool forceUiUpdate)
}
// Tell EInk class to update the display
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay();
#elif defined(USE_EINK)
static_cast<EInkDisplay *>(dispdev)->forceDisplay();
#endif
#else
// No delay between UI frame rendering
if (forceUiUpdate) {
@@ -875,6 +891,10 @@ int32_t Screen::runOnce()
break;
case Cmd::STOP_ALERT_FRAME:
NotificationRenderer::pauseBanner = false;
// Return from one-off alert mode back to regular frames.
if (!showingNormalScreen && NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) {
setFrames();
}
break;
case Cmd::STOP_BOOT_SCREEN:
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame
@@ -985,8 +1005,10 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
ui->update();
} while (ui->getUiState()->lastUpdate < startUpdate);
#if defined(USE_EINK_PARALLELDISPLAY)
static_cast<EInkParallelDisplay *>(dispdev)->forceDisplay(0);
#elif defined(USE_EINK) && !defined(USE_EINK_DYNAMICDISPLAY)
// Old EInkDisplay class
#if !defined(USE_EINK_DYNAMICDISPLAY)
static_cast<EInkDisplay *>(dispdev)->forceDisplay(0); // Screen::forceDisplay(), but override rate-limit
#endif
@@ -998,7 +1020,7 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver)
#ifdef EINK_HASQUIRK_GHOSTING
EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // Really ugly to see ghosting from "screen paused"
#else
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
EINK_ADD_FRAMEFLAG(dispdev, RESPONSIVE); // Really nice to wake screen with a fast-refresh
#endif
}
#endif
@@ -1731,6 +1753,26 @@ int Screen::handleInputEvent(const InputEvent *event)
showFrame(FrameDirection::PREVIOUS);
} else if (event->inputEvent == INPUT_BROKER_RIGHT || event->inputEvent == INPUT_BROKER_USER_PRESS) {
showFrame(FrameDirection::NEXT);
} else if (event->inputEvent == INPUT_BROKER_FN_F1) {
this->ui->switchToFrame(0);
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F2) {
this->ui->switchToFrame(1);
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F3) {
this->ui->switchToFrame(2);
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F4) {
this->ui->switchToFrame(3);
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_FN_F5) {
this->ui->switchToFrame(4);
lastScreenTransition = millis();
setFastFramerate();
} else if (event->inputEvent == INPUT_BROKER_UP_LONG) {
// Long press up button for fast frame switching
showPrevFrame();
+4
View File
@@ -765,7 +765,11 @@ class Screen : public concurrency::OSThread
DebugInfo debugInfo;
/// Display device
#ifdef USE_ST7789
ST7789Spi *dispdev;
#else
OLEDDisplay *dispdev;
#endif
/// UI helper for rendering to frames and switching between them
OLEDDisplayUi *ui;
+3 -3
View File
@@ -20,7 +20,7 @@
#include "graphics/fonts/OLEDDisplayFontsGR.h"
#endif
#if defined(CROWPANEL_ESP32S3_5_EPAPER) && defined(USE_EINK)
#if (defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO)) && defined(USE_EINK)
#include "graphics/fonts/EinkDisplayFonts.h"
#endif
@@ -90,7 +90,7 @@
#if (defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7701_CS) || defined(ST7735_CS) || \
defined(ST7789_CS) || defined(USE_ST7789) || defined(HX8357_CS) || defined(ILI9488_CS) || defined(ST7796_CS) || \
defined(HACKADAY_COMMUNICATOR) || defined(USE_ST7796)) && \
defined(USE_ST7796) || defined(HACKADAY_COMMUNICATOR)) && \
!defined(DISPLAY_FORCE_SMALL_FONTS)
// The screen is bigger so use bigger fonts
#define FONT_SMALL FONT_MEDIUM_LOCAL // Height: 19
@@ -106,7 +106,7 @@
#define FONT_LARGE FONT_LARGE_LOCAL // Height: 28
#endif
#if defined(CROWPANEL_ESP32S3_5_EPAPER) && defined(USE_EINK)
#if defined(CROWPANEL_ESP32S3_5_EPAPER) || defined(T5_S3_EPAPER_PRO)
#undef FONT_SMALL
#undef FONT_MEDIUM
#undef FONT_LARGE
+27 -35
View File
@@ -121,11 +121,10 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
}
// === Screen Title ===
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(SCREEN_WIDTH / 2, y, titleStr);
if (config.display.heading_bold) {
display->drawString((SCREEN_WIDTH / 2) + 1, y, titleStr);
}
const char *headerTitle = titleStr ? titleStr : "";
const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle);
const int titleX = (SCREEN_WIDTH - titleWidth) / 2;
UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold);
}
display->setTextAlignment(TEXT_ALIGN_LEFT);
@@ -221,7 +220,6 @@ void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *ti
if (rtc_sec > 0) {
// === Build Time String ===
long hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY;
int hour, minute, second;
graphics::decomposeTime(rtc_sec, hour, minute, second);
snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute);
@@ -428,39 +426,33 @@ const int *getTextPositions(OLEDDisplay *display)
// *************************
void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y)
{
bool drawConnectionState = false;
if (service->api_state == service->STATE_BLE || service->api_state == service->STATE_WIFI ||
service->api_state == service->STATE_SERIAL || service->api_state == service->STATE_PACKET ||
service->api_state == service->STATE_HTTP || service->api_state == service->STATE_ETH) {
drawConnectionState = true;
}
if (!isAPIConnected(service->api_state))
return;
if (drawConnectionState) {
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
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->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);
const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1;
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->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;
for (int xx = 0; xx < connection_icon_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first
if (byteVal & bitMask) {
display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale);
}
for (int yy = 0; yy < connection_icon_height; ++yy) {
const uint8_t *rowPtr = connection_icon + yy * bytesPerRow;
for (int xx = 0; xx < connection_icon_width; ++xx) {
const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3));
const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first
if (byteVal & bitMask) {
display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale);
}
}
} else {
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
connection_icon);
}
} else {
display->drawXbm(0, SCREEN_HEIGHT - connection_icon_height, connection_icon_width, connection_icon_height,
connection_icon);
}
}
@@ -522,4 +514,4 @@ std::string sanitizeString(const std::string &input)
}
} // namespace graphics
#endif
#endif
+14
View File
@@ -63,4 +63,18 @@ bool isAllowedPunctuation(char c);
std::string sanitizeString(const std::string &input);
static inline bool isAPIConnected(uint8_t state)
{
static constexpr bool connectedStates[] = {
/* STATE_NONE */ false,
/* STATE_BLE */ true,
/* STATE_WIFI */ true,
/* STATE_SERIAL */ true,
/* STATE_PACKET */ true,
/* STATE_HTTP */ true,
/* STATE_ETH */ true,
};
return state < sizeof(connectedStates) ? connectedStates[state] : false;
}
} // namespace graphics
+11 -11
View File
@@ -1209,8 +1209,8 @@ void TFTDisplay::display(bool fromBlank)
bool somethingChanged = false;
// Store colors byte-reversed so that TFT_eSPI doesn't have to swap bytes in a separate step
colorTftMesh = (TFT_MESH >> 8) | ((TFT_MESH & 0xFF) << 8);
colorTftBlack = (TFT_BLACK >> 8) | ((TFT_BLACK & 0xFF) << 8);
colorTftMesh = __builtin_bswap16(TFT_MESH);
colorTftBlack = __builtin_bswap16(TFT_BLACK);
y = 0;
while (y < displayHeight) {
@@ -1348,7 +1348,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOn();
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->wakeup();
tft->powerSaveOff();
#endif
@@ -1359,7 +1359,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(true); // using unPhone library
#endif
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(ST7789_CS) && \
!defined(HACKADAY_COMMUNICATOR) // T-Deck gets brightness set in Screen.cpp in the handleSetOn function
tft->setBrightness(172);
@@ -1375,7 +1375,7 @@ void TFTDisplay::sendCommand(uint8_t com)
digitalWrite(portduino_config.displayBacklight.pin, !TFT_BACKLIGHT_ON);
#elif defined(HACKADAY_COMMUNICATOR)
tft->displayOff();
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE)
#elif !defined(RAK14014) && !defined(M5STACK) && !defined(UNPHONE) && !defined(HELTEC_MESH_NODE_T096)
tft->sleep();
tft->powerSaveOn();
#endif
@@ -1386,7 +1386,7 @@ void TFTDisplay::sendCommand(uint8_t com)
#ifdef UNPHONE
unphone.backlight(false); // using unPhone library
#endif
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(0);
#endif
@@ -1401,7 +1401,7 @@ void TFTDisplay::sendCommand(uint8_t com)
void TFTDisplay::setDisplayBrightness(uint8_t _brightness)
{
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
// todo
#elif !defined(HACKADAY_COMMUNICATOR)
tft->setBrightness(_brightness);
@@ -1421,7 +1421,7 @@ bool TFTDisplay::hasTouch(void)
{
#ifdef RAK14014
return true;
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->touch() != nullptr;
#else
return false;
@@ -1440,7 +1440,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
} else {
return false;
}
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR)
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096)
return tft->getTouch(x, y);
#else
return false;
@@ -1457,7 +1457,7 @@ bool TFTDisplay::connect()
{
concurrency::LockGuard g(spiLock);
LOG_INFO("Do TFT init");
#ifdef RAK14014
#if defined(RAK14014) || defined(HELTEC_MESH_NODE_T096)
tft = new TFT_eSPI;
#elif defined(HACKADAY_COMMUNICATOR)
bus = new Arduino_ESP32SPI(TFT_DC, TFT_CS, 38 /* SCK */, 21 /* MOSI */, GFX_NOT_DEFINED /* MISO */, HSPI /* spi_num */);
@@ -1494,7 +1494,7 @@ bool TFTDisplay::connect()
ft6336u.begin();
pinMode(SCREEN_TOUCH_INT, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(SCREEN_TOUCH_INT), rak14014_tpIntHandle, FALLING);
#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2)
#elif defined(T_DECK) || defined(PICOMPUTER_S3) || defined(CHATTER_2) || defined(HELTEC_MESH_NODE_T096)
tft->setRotation(1); // T-Deck has the TFT in landscape
#elif defined(T_WATCH_S3)
tft->setRotation(2); // T-Watch S3 left-handed orientation
+6 -6
View File
@@ -110,14 +110,14 @@ void getUptimeStr(uint32_t uptimeMillis, const char *prefix, char *uptimeStr, ui
uint32_t secs = (uptimeMillis % 60000) / 1000;
if (days) {
snprintf(uptimeStr, maxLength, "%s: %ud %uh", prefix, days, hours);
snprintf(uptimeStr, maxLength, "%s%ud %uh", prefix, days, hours);
} else if (hours) {
snprintf(uptimeStr, maxLength, "%s: %uh %um", prefix, hours, mins);
snprintf(uptimeStr, maxLength, "%s%uh %um", prefix, hours, mins);
} else if (!includeSecs) {
snprintf(uptimeStr, maxLength, "%s: %um", prefix, mins);
snprintf(uptimeStr, maxLength, "%s%um", prefix, mins);
} else if (mins) {
snprintf(uptimeStr, maxLength, "%s: %um %us", prefix, mins, secs);
snprintf(uptimeStr, maxLength, "%s%um %us", prefix, mins, secs);
} else {
snprintf(uptimeStr, maxLength, "%s: %us", prefix, secs);
snprintf(uptimeStr, maxLength, "%s%us", prefix, secs);
}
}
}
+11 -3
View File
@@ -429,6 +429,10 @@ void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool
c = c - 'a' + 'A';
}
keyText = (key.character == ' ' || key.character == '_') ? "_" : std::string(1, c);
// Show the common "/" pairing next to "?" like on a real keyboard
if (key.type == VK_CHAR && key.character == '?') {
keyText = "?/";
}
}
int textWidth = display->getStringWidth(keyText.c_str());
@@ -518,9 +522,13 @@ char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress)
char c = key.character;
// Long-press: only keep letter lowercase->uppercase conversion; remove other symbol mappings
if (isLongPress && c >= 'a' && c <= 'z') {
c = (char)(c - 'a' + 'A');
// Long-press: letters become uppercase; for "?" provide "/" like a typical keyboard
if (isLongPress) {
if (c >= 'a' && c <= 'z') {
c = (char)(c - 'a' + 'A');
} else if (c == '?') {
c = '/';
}
}
return c;
+10 -4
View File
@@ -535,6 +535,9 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
#ifndef T_DECK_PRO
barsOffset -= 12;
#endif
#if defined(T5_S3_EPAPER_PRO)
barsOffset += 60;
#endif
#endif
int barX = x + barsOffset;
if (currentResolution == ScreenResolution::UltraLow) {
@@ -584,11 +587,12 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
uint32_t heapUsed = memGet.getHeapSize() - memGet.getFreeHeap();
uint32_t heapTotal = memGet.getHeapSize();
uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram();
uint32_t psramTotal = memGet.getPsramSize();
uint32_t flashUsed = 0, flashTotal = 0;
#ifdef ESP32
#ifndef T5_S3_EPAPER_PRO
uint32_t psramUsed = memGet.getPsramSize() - memGet.getFreePsram();
uint32_t psramTotal = memGet.getPsramSize();
#endif
flashUsed = FSCom.usedBytes();
flashTotal = FSCom.totalBytes();
#endif
@@ -607,10 +611,12 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
// === Draw memory rows
drawUsageRow("Heap:", heapUsed, heapTotal, true);
#ifdef ESP32
#ifndef T5_S3_EPAPER_PRO
if (psramUsed > 0) {
line += 1;
drawUsageRow("PSRAM:", psramUsed, psramTotal);
}
#endif
if (flashTotal > 0) {
line += 1;
drawUsageRow("Flash:", flashUsed, flashTotal);
@@ -663,7 +669,7 @@ void drawSystemScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x
if (SCREEN_HEIGHT > 64 || (SCREEN_HEIGHT <= 64 && line <= 5)) { // Only show uptime if the screen can show it
char uptimeStr[32] = "";
getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr));
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
textWidth = display->getStringWidth(uptimeStr);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], uptimeStr);
+202 -194
View File
@@ -58,7 +58,7 @@ BannerOverlayOptions createStaticBannerOptions(const char *message, const MenuOp
} // namespace
menuHandler::screenMenus menuHandler::menuQueue = menu_none;
menuHandler::screenMenus menuHandler::menuQueue = MenuNone;
uint32_t menuHandler::pickedNodeNum = 0;
bool test_enabled = false;
uint8_t test_count = 0;
@@ -66,7 +66,7 @@ uint8_t test_count = 0;
void menuHandler::loraMenu()
{
static const char *optionsArray[] = {"Back", "Device Role", "Radio Preset", "Frequency Slot", "LoRa Region"};
enum optionsNumbers { Back = 0, device_role_picker = 1, radio_preset_picker = 2, frequency_slot = 3, lora_picker = 4 };
enum optionsNumbers { Back = 0, DeviceRolePicker = 1, RadioPresetPicker = 2, FrequencySlot = 3, LoraPicker = 4 };
BannerOverlayOptions bannerOptions;
bannerOptions.message = "LoRa Actions";
bannerOptions.optionsArrayPtr = optionsArray;
@@ -74,14 +74,14 @@ void menuHandler::loraMenu()
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Back) {
// No action
} else if (selected == device_role_picker) {
menuHandler::menuQueue = menuHandler::device_role_picker;
} else if (selected == radio_preset_picker) {
menuHandler::menuQueue = menuHandler::radio_preset_picker;
} else if (selected == frequency_slot) {
menuHandler::menuQueue = menuHandler::frequency_slot;
} else if (selected == lora_picker) {
menuHandler::menuQueue = menuHandler::lora_picker;
} else if (selected == DeviceRolePicker) {
menuHandler::menuQueue = menuHandler::DeviceRolePicker;
} else if (selected == RadioPresetPicker) {
menuHandler::menuQueue = menuHandler::RadioPresetPicker;
} else if (selected == FrequencySlot) {
menuHandler::menuQueue = menuHandler::FrequencySlot;
} else if (selected == LoraPicker) {
menuHandler::menuQueue = menuHandler::LoraPicker;
}
};
screen->showOverlayBanner(bannerOptions);
@@ -102,7 +102,7 @@ void menuHandler::OnboardMessage()
bannerOptions.optionsArrayPtr = optionsArray;
bannerOptions.optionsCount = 2;
bannerOptions.bannerCallback = [](int selected) -> void {
menuHandler::menuQueue = menuHandler::no_timeout_lora_picker;
menuHandler::menuQueue = menuHandler::NoTimeoutLoraPicker;
screen->runNow();
};
screen->showOverlayBanner(bannerOptions);
@@ -216,7 +216,7 @@ void menuHandler::LoraRegionPicker(uint32_t duration)
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::DeviceRolePicker()
void menuHandler::deviceRolePicker()
{
static const char *optionsArray[] = {"Back", "Client", "Client Mute", "Lost and Found", "Tracker"};
enum optionsNumbers {
@@ -232,7 +232,7 @@ void menuHandler::DeviceRolePicker()
bannerOptions.optionsCount = 5;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Back) {
menuHandler::menuQueue = menuHandler::lora_Menu;
menuHandler::menuQueue = menuHandler::LoraMenu;
screen->runNow();
return;
} else if (selected == devicerole_client) {
@@ -266,52 +266,8 @@ void menuHandler::FrequencySlotPicker()
// Calculate number of channels (copied from RadioInterface::applyModemConfig())
meshtastic_Config_LoRaConfig &loraConfig = config.lora;
double bw = loraConfig.bandwidth;
if (loraConfig.use_preset) {
switch (loraConfig.modem_preset) {
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
bw = (myRegion->wideLora) ? 1625.0 : 500;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
bw = (myRegion->wideLora) ? 812.5 : 250;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
bw = (myRegion->wideLora) ? 812.5 : 250;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
bw = (myRegion->wideLora) ? 812.5 : 250;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
bw = (myRegion->wideLora) ? 812.5 : 250;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO:
bw = (myRegion->wideLora) ? 1625.0 : 500;
break;
default:
bw = (myRegion->wideLora) ? 812.5 : 250;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
bw = (myRegion->wideLora) ? 406.25 : 125;
break;
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
bw = (myRegion->wideLora) ? 406.25 : 125;
break;
}
} else {
bw = loraConfig.bandwidth;
if (bw == 31) // This parameter is not an integer
bw = 31.25;
if (bw == 62) // Fix for 62.5Khz bandwidth
bw = 62.5;
if (bw == 200)
bw = 203.125;
if (bw == 400)
bw = 406.25;
if (bw == 800)
bw = 812.5;
if (bw == 1600)
bw = 1625.0;
}
double bw = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, myRegion->wideLora)
: bwCodeToKHz(loraConfig.bandwidth);
uint32_t numChannels = 0;
if (myRegion) {
@@ -344,7 +300,7 @@ void menuHandler::FrequencySlotPicker()
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Back) {
menuHandler::menuQueue = menuHandler::lora_Menu;
menuHandler::menuQueue = menuHandler::LoraMenu;
screen->runNow();
return;
}
@@ -357,7 +313,7 @@ void menuHandler::FrequencySlotPicker()
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::RadioPresetPicker()
void menuHandler::radioPresetPicker()
{
static const RadioPresetOption presetOptions[] = {
{"Back", OptionsAction::Back},
@@ -377,7 +333,7 @@ void menuHandler::RadioPresetPicker()
auto bannerOptions =
createStaticBannerOptions("Radio Preset", presetOptions, presetLabels, [](const RadioPresetOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuHandler::menuQueue = menuHandler::lora_Menu;
menuHandler::menuQueue = menuHandler::LoraMenu;
screen->runNow();
return;
}
@@ -396,7 +352,7 @@ void menuHandler::RadioPresetPicker()
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::TwelveHourPicker()
void menuHandler::twelveHourPicker()
{
static const char *optionsArray[] = {"Back", "12-hour", "24-hour"};
enum optionsNumbers { Back = 0, twelve = 1, twentyfour = 2 };
@@ -406,7 +362,7 @@ void menuHandler::TwelveHourPicker()
bannerOptions.optionsCount = 3;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Back) {
menuHandler::menuQueue = menuHandler::clock_menu;
menuHandler::menuQueue = menuHandler::ClockMenu;
screen->runNow();
} else if (selected == twelve) {
config.display.use_12h_clock = true;
@@ -434,7 +390,7 @@ void menuHandler::showConfirmationBanner(const char *message, std::function<void
screen->showOverlayBanner(confirmBanner);
}
void menuHandler::ClockFacePicker()
void menuHandler::clockFacePicker()
{
static const ClockFaceOption clockFaceOptions[] = {
{"Back", OptionsAction::Back},
@@ -448,7 +404,7 @@ void menuHandler::ClockFacePicker()
auto bannerOptions = createStaticBannerOptions("Which Face?", clockFaceOptions, clockFaceLabels,
[](const ClockFaceOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuHandler::menuQueue = menuHandler::clock_menu;
menuHandler::menuQueue = menuHandler::ClockMenu;
screen->runNow();
return;
}
@@ -500,7 +456,7 @@ void menuHandler::TZPicker()
auto bannerOptions = createStaticBannerOptions(
"Pick Timezone", timezoneOptions, timezoneLabels, [](const TimezoneOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuHandler::menuQueue = menuHandler::clock_menu;
menuHandler::menuQueue = menuHandler::ClockMenu;
screen->runNow();
return;
}
@@ -547,13 +503,13 @@ void menuHandler::clockMenu()
bannerOptions.optionsCount = 4;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Clock) {
menuHandler::menuQueue = menuHandler::clock_face_picker;
menuHandler::menuQueue = menuHandler::ClockFacePicker;
screen->runNow();
} else if (selected == Time) {
menuHandler::menuQueue = menuHandler::twelve_hour_picker;
menuHandler::menuQueue = menuHandler::TwelveHourPicker;
screen->runNow();
} else if (selected == Timezone) {
menuHandler::menuQueue = menuHandler::TZ_picker;
menuHandler::menuQueue = menuHandler::TzPicker;
screen->runNow();
}
};
@@ -583,7 +539,7 @@ void menuHandler::messageResponseMenu()
// If viewing ALL chats, hide “Mute Chat”
if (mode != graphics::MessageRenderer::ThreadMode::ALL && mode != graphics::MessageRenderer::ThreadMode::DIRECT) {
const uint8_t chIndex = (threadChannel != 0) ? (uint8_t)threadChannel : channels.getPrimaryIndex();
auto &chan = channels.getByIndex(chIndex);
const auto &chan = channels.getByIndex(chIndex);
optionsArray[options] = chan.settings.module_settings.is_muted ? "Unmute Channel" : "Mute Channel";
optionsEnumArray[options++] = MuteChannel;
@@ -616,12 +572,12 @@ void menuHandler::messageResponseMenu()
LOG_DEBUG("[ReplyCtx] mode=%d ch=%d peer=0x%08x", (int)mode, ch, (unsigned int)peer);
if (selected == ViewMode) {
menuHandler::menuQueue = menuHandler::message_viewmode_menu;
menuHandler::menuQueue = menuHandler::MessageViewModeMenu;
screen->runNow();
// Reply submenu
} else if (selected == ReplyMenu) {
menuHandler::menuQueue = menuHandler::reply_menu;
menuHandler::menuQueue = menuHandler::ReplyMenu;
screen->runNow();
} else if (selected == MuteChannel) {
@@ -633,7 +589,7 @@ void menuHandler::messageResponseMenu()
}
} else if (selected == DeleteMenu) {
menuHandler::menuQueue = menuHandler::delete_messages_menu;
menuHandler::menuQueue = menuHandler::DeleteMessagesMenu;
screen->runNow();
#ifdef HAS_I2S
@@ -693,7 +649,7 @@ void menuHandler::replyMenu()
uint32_t peer = graphics::MessageRenderer::getThreadPeer();
if (selected == Back) {
menuHandler::menuQueue = menuHandler::message_response_menu;
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
screen->runNow();
return;
}
@@ -781,7 +737,7 @@ void menuHandler::deleteMessagesMenu()
uint32_t peer = graphics::MessageRenderer::getThreadPeer();
if (selected == Back) {
menuHandler::menuQueue = menuHandler::message_response_menu;
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
screen->runNow();
return;
}
@@ -875,7 +831,7 @@ void menuHandler::messageViewModeMenu()
// Gather unique peers
auto dms = messageStore.getDirectMessages();
std::vector<uint32_t> uniquePeers;
for (auto &m : dms) {
for (const auto &m : dms) {
uint32_t peer = (m.sender == nodeDB->getNodeNum()) ? m.dest : m.sender;
if (peer != nodeDB->getNodeNum() && std::find(uniquePeers.begin(), uniquePeers.end(), peer) == uniquePeers.end())
uniquePeers.push_back(peer);
@@ -945,7 +901,7 @@ void menuHandler::messageViewModeMenu()
bannerOptions.bannerCallback = [=](int selected) -> void {
LOG_DEBUG("messageViewModeMenu: selected=%d", selected);
if (selected == -1) {
menuHandler::menuQueue = menuHandler::message_response_menu;
menuHandler::menuQueue = menuHandler::MessageResponseMenu;
screen->runNow();
} else if (selected == -2) {
graphics::MessageRenderer::setThreadMode(graphics::MessageRenderer::ThreadMode::ALL);
@@ -1127,23 +1083,23 @@ void menuHandler::systemBaseMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Notifications) {
menuHandler::menuQueue = menuHandler::buzzermodemenupicker;
menuHandler::menuQueue = menuHandler::BuzzerModeMenuPicker;
screen->runNow();
} else if (selected == ScreenOptions) {
menuHandler::menuQueue = menuHandler::screen_options_menu;
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
screen->runNow();
} else if (selected == PowerMenu) {
menuHandler::menuQueue = menuHandler::power_menu;
menuHandler::menuQueue = menuHandler::PowerMenu;
screen->runNow();
} else if (selected == Test) {
menuHandler::menuQueue = menuHandler::test_menu;
menuHandler::menuQueue = menuHandler::TestMenu;
screen->runNow();
} else if (selected == Bluetooth) {
menuQueue = bluetooth_toggle_menu;
menuQueue = BluetoothToggleMenu;
screen->runNow();
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
} else if (selected == WiFiToggle) {
menuQueue = wifi_toggle_menu;
menuQueue = WifiToggleMenu;
screen->runNow();
#endif
} else if (selected == Back && !test_enabled) {
@@ -1221,7 +1177,7 @@ void menuHandler::favoriteBaseMenu()
evt.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
screen->handleUIFrameEvent(&evt);
} else if (selected == Remove) {
menuHandler::menuQueue = menuHandler::remove_favorite;
menuHandler::menuQueue = menuHandler::RemoveFavorite;
screen->runNow();
} else if (selected == TraceRoute) {
if (traceRouteModule) {
@@ -1266,9 +1222,11 @@ void menuHandler::positionBaseMenu()
};
constexpr size_t baseCount = sizeof(baseOptions) / sizeof(baseOptions[0]);
constexpr size_t calibrateCount = sizeof(calibrateOptions) / sizeof(calibrateOptions[0]);
static std::array<const char *, baseCount> baseLabels{};
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
constexpr size_t calibrateCount = sizeof(calibrateOptions) / sizeof(calibrateOptions[0]);
static std::array<const char *, calibrateCount> calibrateLabels{};
#endif
auto onSelection = [](const PositionMenuOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
@@ -1282,43 +1240,49 @@ void menuHandler::positionBaseMenu()
auto action = static_cast<PositionAction>(option.value);
switch (action) {
case PositionAction::GpsToggle:
menuQueue = gps_toggle_menu;
menuQueue = GpsToggleMenu;
screen->runNow();
break;
case PositionAction::GpsFormat:
menuQueue = gps_format_menu;
menuQueue = GpsFormatMenu;
screen->runNow();
break;
case PositionAction::CompassMenu:
menuQueue = compass_point_north_menu;
menuQueue = CompassPointNorthMenu;
screen->runNow();
break;
case PositionAction::CompassCalibrate:
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
if (accelerometerThread) {
accelerometerThread->calibrate(30);
}
#endif
break;
case PositionAction::GPSSmartPosition:
menuQueue = gps_smart_position_menu;
menuQueue = GpsSmartPositionMenu;
screen->runNow();
break;
case PositionAction::GPSUpdateInterval:
menuQueue = gps_update_interval_menu;
menuQueue = GpsUpdateIntervalMenu;
screen->runNow();
break;
case PositionAction::GPSPositionBroadcast:
menuQueue = gps_position_broadcast_menu;
menuQueue = GpsPositionBroadcastMenu;
screen->runNow();
break;
}
};
BannerOverlayOptions bannerOptions;
#if !MESHTASTIC_EXCLUDE_ACCELEROMETER
if (accelerometerThread) {
bannerOptions = createStaticBannerOptions("GPS Action", calibrateOptions, calibrateLabels, onSelection);
} else {
bannerOptions = createStaticBannerOptions("GPS Action", baseOptions, baseLabels, onSelection);
}
#else
bannerOptions = createStaticBannerOptions("GPS Action", baseOptions, baseLabels, onSelection);
#endif
screen->showOverlayBanner(bannerOptions);
}
@@ -1347,13 +1311,13 @@ void menuHandler::nodeListMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == NodePicker) {
menuQueue = NodePicker_menu;
menuQueue = NodePickerMenu;
screen->runNow();
} else if (selected == Reset) {
menuQueue = reset_node_db_menu;
menuQueue = ResetNodeDbMenu;
screen->runNow();
} else if (selected == NodeNameLength) {
menuHandler::menuQueue = menuHandler::node_name_length_menu;
menuHandler::menuQueue = menuHandler::NodeNameLengthMenu;
screen->runNow();
}
};
@@ -1374,12 +1338,12 @@ void menuHandler::NodePicker()
menuHandler::pickedNodeNum = nodenum;
// Keep UI favorite context in sync (used elsewhere for some node-based actions)
graphics::UIRenderer::currentFavoriteNodeNum = nodenum;
menuQueue = Manage_Node_menu;
menuQueue = ManageNodeMenu;
screen->runNow();
});
}
void menuHandler::ManageNodeMenu()
void menuHandler::manageNodeMenu()
{
// If we don't have a node selected yet, go fast exit
auto node = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
@@ -1435,13 +1399,13 @@ void menuHandler::ManageNodeMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Back) {
menuQueue = node_base_menu;
menuQueue = NodeBaseMenu;
screen->runNow();
return;
}
if (selected == Favorite) {
auto n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
const auto *n = nodeDB->getMeshNode(menuHandler::pickedNodeNum);
if (!n) {
return;
}
@@ -1527,7 +1491,7 @@ void menuHandler::nodeNameLengthMenu()
auto bannerOptions = createStaticBannerOptions("Node Name Length", nodeNameOptions, nodeNameLabels,
[](const NodeNameOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = node_base_menu;
menuQueue = NodeBaseMenu;
screen->runNow();
return;
}
@@ -1542,6 +1506,7 @@ void menuHandler::nodeNameLengthMenu()
config.display.use_long_node_name = option.value;
saveUIConfig();
service->reloadConfig(SEGMENT_CONFIG);
LOG_INFO("Setting names to %s", option.value ? "long" : "short");
});
@@ -1572,7 +1537,7 @@ void menuHandler::resetNodeDBMenu()
nodeDB->resetNodes(1);
rebootAtMsec = (millis() + DEFAULT_REBOOT_SECONDS * 1000);
} else if (selected == 0) {
menuQueue = node_base_menu;
menuQueue = NodeBaseMenu;
screen->runNow();
}
};
@@ -1594,7 +1559,7 @@ void menuHandler::compassNorthMenu()
auto bannerOptions = createStaticBannerOptions("North Directions?", compassOptions, compassLabels,
[](const CompassOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
return;
}
@@ -1639,7 +1604,7 @@ void menuHandler::GPSToggleMenu()
auto bannerOptions =
createStaticBannerOptions("Toggle GPS", gpsToggleOptions, toggleLabels, [](const GPSToggleOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
return;
}
@@ -1704,7 +1669,7 @@ void menuHandler::GPSFormatMenu()
auto onSelection = [](const GPSFormatOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
return;
}
@@ -1759,7 +1724,7 @@ void menuHandler::GPSSmartPositionMenu()
bannerOptions.optionsCount = 3;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == 0) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
} else if (selected == 1) {
config.position.position_broadcast_smart_enabled = true;
@@ -1788,7 +1753,7 @@ void menuHandler::GPSUpdateIntervalMenu()
bannerOptions.optionsCount = 16;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == 0) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
} else if (selected == 1) {
config.position.gps_update_interval = 8;
@@ -1876,7 +1841,7 @@ void menuHandler::GPSPositionBroadcastMenu()
bannerOptions.optionsCount = 17;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == 0) {
menuQueue = position_base_menu;
menuQueue = PositionBaseMenu;
screen->runNow();
} else if (selected == 1) {
config.position.position_broadcast_secs = 60;
@@ -1959,7 +1924,7 @@ void menuHandler::GPSPositionBroadcastMenu()
#endif
void menuHandler::BluetoothToggleMenu()
void menuHandler::bluetoothToggleMenu()
{
static const char *optionsArray[] = {"Back", "Enabled", "Disabled"};
BannerOverlayOptions bannerOptions;
@@ -2087,7 +2052,7 @@ void menuHandler::TFTColorPickerMenu(OLEDDisplay *display)
auto bannerOptions = createStaticBannerOptions(
"Select Screen Color", colorOptions, colorLabels, [display](const ScreenColorOption &option, int) -> void {
if (option.action == OptionsAction::Back) {
menuQueue = system_base_menu;
menuQueue = SystemBaseMenu;
screen->runNow();
return;
}
@@ -2182,7 +2147,7 @@ void menuHandler::rebootMenu()
messageStore.saveToFlash();
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
} else {
menuQueue = power_menu;
menuQueue = PowerMenu;
screen->runNow();
}
};
@@ -2204,7 +2169,7 @@ void menuHandler::shutdownMenu()
InputEvent event = {.inputEvent = (input_broker_event)INPUT_BROKER_SHUTDOWN, .kbchar = 0, .touchX = 0, .touchY = 0};
inputBroker->injectInputEvent(&event);
} else {
menuQueue = power_menu;
menuQueue = PowerMenu;
screen->runNow();
}
};
@@ -2247,9 +2212,9 @@ void menuHandler::traceRouteMenu()
void menuHandler::testMenu()
{
enum optionsNumbers { Back, NumberPicker, ShowChirpy };
static const char *optionsArray[4] = {"Back"};
static int optionsEnumArray[4] = {Back};
enum optionsNumbers { Back, NumberPicker, ShowChirpy, TestAnnounce };
static const char *optionsArray[5] = {"Back"};
static int optionsEnumArray[5] = {Back};
int options = 1;
optionsArray[options] = "Number Picker";
@@ -2257,6 +2222,10 @@ void menuHandler::testMenu()
optionsArray[options] = screen->isFrameHidden("chirpy") ? "Show Chirpy" : "Hide Chirpy";
optionsEnumArray[options++] = ShowChirpy;
#ifdef HAS_I2S
optionsArray[options] = "Test Announce";
optionsEnumArray[options++] = TestAnnounce;
#endif
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Hidden Test Menu";
@@ -2265,14 +2234,18 @@ void menuHandler::testMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == NumberPicker) {
menuQueue = number_test;
menuQueue = NumberTest;
screen->runNow();
} else if (selected == ShowChirpy) {
screen->toggleFrameVisibility("chirpy");
screen->setFrames(Screen::FOCUS_SYSTEM);
} else if (selected == TestAnnounce) {
#ifdef HAS_I2S
audioThread->readAloud("This is a test of the emergency broadcast system. This is only a test.");
#endif
} else {
menuQueue = system_base_menu;
menuQueue = SystemBaseMenu;
screen->runNow();
}
};
@@ -2296,7 +2269,7 @@ void menuHandler::wifiBaseMenu()
bannerOptions.optionsCount = 2;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Wifi_toggle) {
menuQueue = wifi_toggle_menu;
menuQueue = WifiToggleMenu;
screen->runNow();
}
};
@@ -2335,19 +2308,18 @@ void menuHandler::wifiToggleMenu()
void menuHandler::screenOptionsMenu()
{
// Check if brightness is supported
bool hasSupportBrightness = false;
#if defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
hasSupportBrightness = true;
#endif
#if defined(T_DECK)
// TDeck Doesn't seem to support brightness at all, at least not reliably
hasSupportBrightness = false;
bool hasSupportBrightness = false;
#elif defined(ST7789_CS) || defined(USE_OLED) || defined(USE_SSD1306) || defined(USE_SH1106) || defined(USE_SH1107)
bool hasSupportBrightness = true;
#else
bool hasSupportBrightness = false;
#endif
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits };
static const char *optionsArray[5] = {"Back"};
static int optionsEnumArray[5] = {Back};
enum optionsNumbers { Back, Brightness, ScreenColor, FrameToggles, DisplayUnits, MessageBubbles };
static const char *optionsArray[6] = {"Back"};
static int optionsEnumArray[6] = {Back};
int options = 1;
// Only show brightness for B&W displays
@@ -2369,6 +2341,9 @@ void menuHandler::screenOptionsMenu()
optionsArray[options] = "Display Units";
optionsEnumArray[options++] = DisplayUnits;
optionsArray[options] = "Message Bubbles";
optionsEnumArray[options++] = MessageBubbles;
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Display Options";
bannerOptions.optionsArrayPtr = optionsArray;
@@ -2376,10 +2351,10 @@ void menuHandler::screenOptionsMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Brightness) {
menuHandler::menuQueue = menuHandler::brightness_picker;
menuHandler::menuQueue = menuHandler::BrightnessPicker;
screen->runNow();
} else if (selected == ScreenColor) {
menuHandler::menuQueue = menuHandler::tftcolormenupicker;
menuHandler::menuQueue = menuHandler::TftColorMenuPicker;
screen->runNow();
} else if (selected == FrameToggles) {
menuHandler::menuQueue = menuHandler::FrameToggles;
@@ -2387,8 +2362,11 @@ void menuHandler::screenOptionsMenu()
} else if (selected == DisplayUnits) {
menuHandler::menuQueue = menuHandler::DisplayUnits;
screen->runNow();
} else if (selected == MessageBubbles) {
menuHandler::menuQueue = menuHandler::MessageBubblesMenu;
screen->runNow();
} else {
menuQueue = system_base_menu;
menuQueue = SystemBaseMenu;
screen->runNow();
}
};
@@ -2424,16 +2402,16 @@ void menuHandler::powerMenu()
bannerOptions.optionsEnumPtr = optionsEnumArray;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == Reboot) {
menuHandler::menuQueue = menuHandler::reboot_menu;
menuHandler::menuQueue = menuHandler::RebootMenu;
screen->runNow();
} else if (selected == Shutdown) {
menuHandler::menuQueue = menuHandler::shutdown_menu;
menuHandler::menuQueue = menuHandler::ShutdownMenu;
screen->runNow();
} else if (selected == MUI) {
menuHandler::menuQueue = menuHandler::mui_picker;
menuHandler::menuQueue = menuHandler::MuiPicker;
screen->runNow();
} else {
menuQueue = system_base_menu;
menuQueue = SystemBaseMenu;
screen->runNow();
}
};
@@ -2471,7 +2449,7 @@ void menuHandler::keyVerificationFinalPrompt()
}
}
void menuHandler::FrameToggles_menu()
void menuHandler::frameTogglesMenu()
{
enum optionsNumbers {
Finish,
@@ -2481,7 +2459,7 @@ void menuHandler::FrameToggles_menu()
nodelist_hopsignal,
nodelist_distance,
nodelist_bearings,
gps,
gps_position,
lora,
clock,
show_favorites,
@@ -2519,7 +2497,7 @@ void menuHandler::FrameToggles_menu()
#endif
optionsArray[options] = screen->isFrameHidden("gps") ? "Show Position" : "Hide Position";
optionsEnumArray[options++] = gps;
optionsEnumArray[options++] = gps_position;
#endif
optionsArray[options] = screen->isFrameHidden("lora") ? "Show LoRa" : "Hide LoRa";
@@ -2582,7 +2560,7 @@ void menuHandler::FrameToggles_menu()
screen->toggleFrameVisibility("nodelist_bearings");
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
} else if (selected == gps) {
} else if (selected == gps_position) {
screen->toggleFrameVisibility("gps");
menuHandler::menuQueue = menuHandler::FrameToggles;
screen->runNow();
@@ -2615,7 +2593,7 @@ void menuHandler::FrameToggles_menu()
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::DisplayUnits_menu()
void menuHandler::displayUnitsMenu()
{
enum optionsNumbers { Back, MetricUnits, ImperialUnits };
@@ -2636,7 +2614,34 @@ void menuHandler::DisplayUnits_menu()
config.display.units = meshtastic_Config_DisplayConfig_DisplayUnits_IMPERIAL;
service->reloadConfig(SEGMENT_CONFIG);
} else {
menuHandler::menuQueue = menuHandler::screen_options_menu;
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
screen->runNow();
}
};
screen->showOverlayBanner(bannerOptions);
}
void menuHandler::messageBubblesMenu()
{
enum optionsNumbers { Back, ShowBubbles, HideBubbles };
static const char *optionsArray[] = {"Back", "Show Bubbles", "Hide Bubbles"};
BannerOverlayOptions bannerOptions;
bannerOptions.message = "Message Bubbles";
bannerOptions.optionsArrayPtr = optionsArray;
bannerOptions.optionsCount = 3;
bannerOptions.InitialSelected = config.display.enable_message_bubbles ? 1 : 2;
bannerOptions.bannerCallback = [](int selected) -> void {
if (selected == ShowBubbles) {
config.display.enable_message_bubbles = true;
service->reloadConfig(SEGMENT_CONFIG);
LOG_INFO("Message bubbles enabled");
} else if (selected == HideBubbles) {
config.display.enable_message_bubbles = false;
service->reloadConfig(SEGMENT_CONFIG);
LOG_INFO("Message bubbles disabled");
} else {
menuHandler::menuQueue = menuHandler::ScreenOptionsMenu;
screen->runNow();
}
};
@@ -2645,153 +2650,156 @@ void menuHandler::DisplayUnits_menu()
void menuHandler::handleMenuSwitch(OLEDDisplay *display)
{
if (menuQueue != menu_none)
if (menuQueue != MenuNone)
test_count = 0;
switch (menuQueue) {
case menu_none:
case MenuNone:
break;
case lora_Menu:
case LoraMenu:
loraMenu();
break;
case lora_picker:
case LoraPicker:
LoraRegionPicker();
break;
case device_role_picker:
DeviceRolePicker();
case DeviceRolePicker:
deviceRolePicker();
break;
case radio_preset_picker:
RadioPresetPicker();
case RadioPresetPicker:
radioPresetPicker();
break;
case frequency_slot:
case FrequencySlot:
FrequencySlotPicker();
break;
case no_timeout_lora_picker:
case NoTimeoutLoraPicker:
LoraRegionPicker(0);
break;
case TZ_picker:
case TzPicker:
TZPicker();
break;
case twelve_hour_picker:
TwelveHourPicker();
case TwelveHourPicker:
twelveHourPicker();
break;
case clock_face_picker:
ClockFacePicker();
case ClockFacePicker:
clockFacePicker();
break;
case clock_menu:
case ClockMenu:
clockMenu();
break;
case system_base_menu:
case SystemBaseMenu:
systemBaseMenu();
break;
case position_base_menu:
case PositionBaseMenu:
positionBaseMenu();
break;
case node_base_menu:
case NodeBaseMenu:
nodeListMenu();
break;
#if !MESHTASTIC_EXCLUDE_GPS
case gps_toggle_menu:
case GpsToggleMenu:
GPSToggleMenu();
break;
case gps_format_menu:
case GpsFormatMenu:
GPSFormatMenu();
break;
case gps_smart_position_menu:
case GpsSmartPositionMenu:
GPSSmartPositionMenu();
break;
case gps_update_interval_menu:
case GpsUpdateIntervalMenu:
GPSUpdateIntervalMenu();
break;
case gps_position_broadcast_menu:
case GpsPositionBroadcastMenu:
GPSPositionBroadcastMenu();
break;
#endif
case compass_point_north_menu:
case CompassPointNorthMenu:
compassNorthMenu();
break;
case reset_node_db_menu:
case ResetNodeDbMenu:
resetNodeDBMenu();
break;
case buzzermodemenupicker:
case BuzzerModeMenuPicker:
BuzzerModeMenu();
break;
case mui_picker:
case MuiPicker:
switchToMUIMenu();
break;
case tftcolormenupicker:
case TftColorMenuPicker:
TFTColorPickerMenu(display);
break;
case brightness_picker:
case BrightnessPicker:
BrightnessPickerMenu();
break;
case node_name_length_menu:
case NodeNameLengthMenu:
nodeNameLengthMenu();
break;
case reboot_menu:
case RebootMenu:
rebootMenu();
break;
case shutdown_menu:
case ShutdownMenu:
shutdownMenu();
break;
case NodePicker_menu:
case NodePickerMenu:
NodePicker();
break;
case Manage_Node_menu:
ManageNodeMenu();
case ManageNodeMenu:
manageNodeMenu();
break;
case remove_favorite:
case RemoveFavorite:
removeFavoriteMenu();
break;
case trace_route_menu:
case TraceRouteMenu:
traceRouteMenu();
break;
case test_menu:
case TestMenu:
testMenu();
break;
case number_test:
case NumberTest:
numberTest();
break;
case wifi_toggle_menu:
case WifiToggleMenu:
wifiToggleMenu();
break;
case key_verification_init:
case KeyVerificationInit:
keyVerificationInitMenu();
break;
case key_verification_final_prompt:
case KeyVerificationFinalPrompt:
keyVerificationFinalPrompt();
break;
case bluetooth_toggle_menu:
BluetoothToggleMenu();
case BluetoothToggleMenu:
bluetoothToggleMenu();
break;
case screen_options_menu:
case ScreenOptionsMenu:
screenOptionsMenu();
break;
case power_menu:
case PowerMenu:
powerMenu();
break;
case FrameToggles:
FrameToggles_menu();
frameTogglesMenu();
break;
case DisplayUnits:
DisplayUnits_menu();
displayUnitsMenu();
break;
case throttle_message:
case ThrottleMessage:
screen->showSimpleBanner("Too Many Attempts\nTry again in 60 seconds.", 5000);
break;
case message_response_menu:
case MessageResponseMenu:
messageResponseMenu();
break;
case reply_menu:
case ReplyMenu:
replyMenu();
break;
case delete_messages_menu:
case DeleteMessagesMenu:
deleteMessagesMenu();
break;
case message_viewmode_menu:
case MessageViewModeMenu:
messageViewModeMenu();
break;
case MessageBubblesMenu:
messageBubblesMenu();
break;
}
menuQueue = menu_none;
menuQueue = MenuNone;
}
void menuHandler::saveUIConfig()
+57 -55
View File
@@ -8,53 +8,54 @@ class menuHandler
{
public:
enum screenMenus {
menu_none,
lora_Menu,
lora_picker,
device_role_picker,
radio_preset_picker,
frequency_slot,
no_timeout_lora_picker,
TZ_picker,
twelve_hour_picker,
clock_face_picker,
clock_menu,
position_base_menu,
node_base_menu,
gps_toggle_menu,
gps_format_menu,
gps_smart_position_menu,
gps_update_interval_menu,
gps_position_broadcast_menu,
compass_point_north_menu,
reset_node_db_menu,
buzzermodemenupicker,
mui_picker,
tftcolormenupicker,
brightness_picker,
reboot_menu,
shutdown_menu,
NodePicker_menu,
Manage_Node_menu,
remove_favorite,
test_menu,
number_test,
wifi_toggle_menu,
bluetooth_toggle_menu,
screen_options_menu,
power_menu,
system_base_menu,
key_verification_init,
key_verification_final_prompt,
trace_route_menu,
throttle_message,
message_response_menu,
message_viewmode_menu,
reply_menu,
delete_messages_menu,
node_name_length_menu,
MenuNone,
LoraMenu,
LoraPicker,
DeviceRolePicker,
RadioPresetPicker,
FrequencySlot,
NoTimeoutLoraPicker,
TzPicker,
TwelveHourPicker,
ClockFacePicker,
ClockMenu,
PositionBaseMenu,
NodeBaseMenu,
GpsToggleMenu,
GpsFormatMenu,
GpsSmartPositionMenu,
GpsUpdateIntervalMenu,
GpsPositionBroadcastMenu,
CompassPointNorthMenu,
ResetNodeDbMenu,
BuzzerModeMenuPicker,
MuiPicker,
TftColorMenuPicker,
BrightnessPicker,
RebootMenu,
ShutdownMenu,
NodePickerMenu,
ManageNodeMenu,
RemoveFavorite,
TestMenu,
NumberTest,
WifiToggleMenu,
BluetoothToggleMenu,
ScreenOptionsMenu,
PowerMenu,
SystemBaseMenu,
KeyVerificationInit,
KeyVerificationFinalPrompt,
TraceRouteMenu,
ThrottleMessage,
MessageResponseMenu,
MessageViewModeMenu,
ReplyMenu,
DeleteMessagesMenu,
NodeNameLengthMenu,
FrameToggles,
DisplayUnits
DisplayUnits,
MessageBubblesMenu
};
static screenMenus menuQueue;
static uint32_t pickedNodeNum; // node selected by NodePicker for ManageNodeMenu
@@ -62,15 +63,15 @@ class menuHandler
static void OnboardMessage();
static void LoraRegionPicker(uint32_t duration = 30000);
static void loraMenu();
static void DeviceRolePicker();
static void RadioPresetPicker();
static void deviceRolePicker();
static void radioPresetPicker();
static void FrequencySlotPicker();
static void handleMenuSwitch(OLEDDisplay *display);
static void showConfirmationBanner(const char *message, std::function<void()> onConfirm);
static void clockMenu();
static void TZPicker();
static void TwelveHourPicker();
static void ClockFacePicker();
static void twelveHourPicker();
static void clockFacePicker();
static void messageResponseMenu();
static void messageViewModeMenu();
static void replyMenu();
@@ -95,7 +96,7 @@ class menuHandler
static void rebootMenu();
static void shutdownMenu();
static void NodePicker();
static void ManageNodeMenu();
static void manageNodeMenu();
static void addFavoriteMenu();
static void removeFavoriteMenu();
static void traceRouteMenu();
@@ -106,15 +107,16 @@ class menuHandler
static void screenOptionsMenu();
static void powerMenu();
static void nodeNameLengthMenu();
static void FrameToggles_menu();
static void DisplayUnits_menu();
static void frameTogglesMenu();
static void displayUnitsMenu();
static void messageBubblesMenu();
static void textMessageMenu();
private:
static void saveUIConfig();
static void keyVerificationInitMenu();
static void keyVerificationFinalPrompt();
static void BluetoothToggleMenu();
static void bluetoothToggleMenu();
};
/* Generic Menu Options designations */
@@ -140,7 +142,7 @@ struct ScreenColor {
uint8_t b;
bool useVariant;
ScreenColor(uint8_t rIn = 0, uint8_t gIn = 0, uint8_t bIn = 0, bool variantIn = false)
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)
{
}
+307 -277
View File
@@ -6,8 +6,8 @@
#include "MessageStore.h"
#include "NodeDB.h"
#include "UIRenderer.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
@@ -20,7 +20,6 @@
// External declarations
extern bool hasUnreadMessage;
extern meshtastic_DeviceState devicestate;
extern graphics::Screen *screen;
using graphics::Emote;
@@ -36,44 +35,6 @@ static std::vector<std::string> cachedLines;
static std::vector<int> cachedHeights;
static bool manualScrolling = false;
// UTF-8 skip helper
static inline size_t utf8CharLen(uint8_t c)
{
if ((c & 0xE0) == 0xC0)
return 2;
if ((c & 0xF0) == 0xE0)
return 3;
if ((c & 0xF8) == 0xF0)
return 4;
return 1;
}
// Remove variation selectors (FE0F) and skin tone modifiers from emoji so they match your labels
std::string normalizeEmoji(const std::string &s)
{
std::string out;
for (size_t i = 0; i < s.size();) {
uint8_t c = static_cast<uint8_t>(s[i]);
size_t len = utf8CharLen(c);
if (c == 0xEF && i + 2 < s.size() && (uint8_t)s[i + 1] == 0xB8 && (uint8_t)s[i + 2] == 0x8F) {
i += 3;
continue;
}
// Skip skin tone modifiers
if (c == 0xF0 && i + 3 < s.size() && (uint8_t)s[i + 1] == 0x9F && (uint8_t)s[i + 2] == 0x8F &&
((uint8_t)s[i + 3] >= 0xBB && (uint8_t)s[i + 3] <= 0xBF)) {
i += 4;
continue;
}
out.append(s, i, len);
i += len;
}
return out;
}
// Scroll state (file scope so we can reset on new message)
float scrollY = 0.0f;
uint32_t lastTime = 0;
@@ -82,6 +43,7 @@ uint32_t pauseStart = 0;
bool waitingToReset = false;
bool scrollStarted = false;
static bool didReset = false;
static constexpr int MESSAGE_BLOCK_GAP = 6;
void scrollUp()
{
@@ -111,119 +73,7 @@ void scrollDown()
void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, const Emote *emotes, int emoteCount)
{
std::string renderLine;
for (size_t i = 0; i < line.size();) {
uint8_t c = (uint8_t)line[i];
size_t len = utf8CharLen(c);
if (c == 0xEF && i + 2 < line.size() && (uint8_t)line[i + 1] == 0xB8 && (uint8_t)line[i + 2] == 0x8F) {
i += 3;
continue;
}
if (c == 0xF0 && i + 3 < line.size() && (uint8_t)line[i + 1] == 0x9F && (uint8_t)line[i + 2] == 0x8F &&
((uint8_t)line[i + 3] >= 0xBB && (uint8_t)line[i + 3] <= 0xBF)) {
i += 4;
continue;
}
renderLine.append(line, i, len);
i += len;
}
int cursorX = x;
const int fontHeight = FONT_HEIGHT_SMALL;
// Step 1: Find tallest emote in the line
int maxIconHeight = fontHeight;
for (size_t i = 0; i < line.length();) {
bool matched = false;
for (int e = 0; e < emoteCount; ++e) {
size_t emojiLen = strlen(emotes[e].label);
if (line.compare(i, emojiLen, emotes[e].label) == 0) {
if (emotes[e].height > maxIconHeight)
maxIconHeight = emotes[e].height;
i += emojiLen;
matched = true;
break;
}
}
if (!matched) {
i += utf8CharLen(static_cast<uint8_t>(line[i]));
}
}
// Step 2: Baseline alignment
int lineHeight = std::max(fontHeight, maxIconHeight);
int baselineOffset = (lineHeight - fontHeight) / 2;
int fontY = y + baselineOffset;
// Step 3: Render line in segments
size_t i = 0;
bool inBold = false;
while (i < line.length()) {
// Check for ** start/end for faux bold
if (line.compare(i, 2, "**") == 0) {
inBold = !inBold;
i += 2;
continue;
}
// Look ahead for the next emote match
size_t nextEmotePos = std::string::npos;
const Emote *matchedEmote = nullptr;
size_t emojiLen = 0;
for (int e = 0; e < emoteCount; ++e) {
size_t pos = line.find(emotes[e].label, i);
if (pos != std::string::npos && (nextEmotePos == std::string::npos || pos < nextEmotePos)) {
nextEmotePos = pos;
matchedEmote = &emotes[e];
emojiLen = strlen(emotes[e].label);
}
}
// Render normal text segment up to the emote or bold toggle
size_t nextControl = std::min(nextEmotePos, line.find("**", i));
if (nextControl == std::string::npos)
nextControl = line.length();
if (nextControl > i) {
std::string textChunk = line.substr(i, nextControl - i);
if (inBold) {
// Faux bold: draw twice, offset by 1px
display->drawString(cursorX + 1, fontY, textChunk.c_str());
}
display->drawString(cursorX, fontY, textChunk.c_str());
#if defined(OLED_UA) || defined(OLED_RU)
cursorX += display->getStringWidth(textChunk.c_str(), textChunk.length(), true);
#else
cursorX += display->getStringWidth(textChunk.c_str());
#endif
i = nextControl;
continue;
}
// Render the emote (if found)
if (matchedEmote && i == nextEmotePos) {
// Vertically center emote relative to font baseline (not just midline)
int iconY = fontY + (fontHeight - matchedEmote->height) / 2;
display->drawXbm(cursorX, iconY, matchedEmote->width, matchedEmote->height, matchedEmote->bitmap);
cursorX += matchedEmote->width + 1;
i += emojiLen;
continue;
} else {
// No more emotes — render the rest of the line
std::string remaining = line.substr(i);
if (inBold) {
display->drawString(cursorX + 1, fontY, remaining.c_str());
}
display->drawString(cursorX, fontY, remaining.c_str());
#if defined(OLED_UA) || defined(OLED_RU)
cursorX += display->getStringWidth(remaining.c_str(), remaining.length(), true);
#else
cursorX += display->getStringWidth(remaining.c_str());
#endif
break;
}
}
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, FONT_HEIGHT_SMALL, emotes, emoteCount);
}
// Reset scroll state when new messages arrive
@@ -395,32 +245,58 @@ static void drawRelayMark(OLEDDisplay *display, int x, int y, int size = 8)
static inline int getRenderedLineWidth(OLEDDisplay *display, const std::string &line, const Emote *emotes, int emoteCount)
{
std::string normalized = normalizeEmoji(line);
int totalWidth = 0;
return graphics::EmoteRenderer::analyzeLine(display, line, 0, emotes, emoteCount).width;
}
size_t i = 0;
while (i < normalized.length()) {
bool matched = false;
for (int e = 0; e < emoteCount; ++e) {
size_t emojiLen = strlen(emotes[e].label);
if (normalized.compare(i, emojiLen, emotes[e].label) == 0) {
totalWidth += emotes[e].width + 1; // +1 spacing
i += emojiLen;
matched = true;
break;
}
}
if (!matched) {
size_t charLen = utf8CharLen(static_cast<uint8_t>(normalized[i]));
#if defined(OLED_UA) || defined(OLED_RU)
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str(), charLen, true);
#else
totalWidth += display->getStringWidth(normalized.substr(i, charLen).c_str());
#endif
i += charLen;
struct MessageBlock {
size_t start;
size_t end;
bool mine;
};
static int getDrawnLinePixelBottom(int lineTopY, const std::string &line, bool isHeaderLine)
{
if (isHeaderLine) {
return lineTopY + (FONT_HEIGHT_SMALL - 1);
}
const int tallest = graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes).tallestHeight;
const int lineHeight = std::max(FONT_HEIGHT_SMALL, tallest);
const int iconTop = lineTopY + (lineHeight - tallest) / 2;
return iconTop + tallest - 1;
}
static std::vector<MessageBlock> buildMessageBlocks(const std::vector<bool> &isHeaderVec, const std::vector<bool> &isMineVec)
{
std::vector<MessageBlock> blocks;
if (isHeaderVec.empty())
return blocks;
size_t start = 0;
bool mine = isMineVec[0];
for (size_t i = 1; i < isHeaderVec.size(); ++i) {
if (isHeaderVec[i]) {
MessageBlock b;
b.start = start;
b.end = i - 1;
b.mine = mine;
blocks.push_back(b);
start = i;
mine = isMineVec[i];
}
}
return totalWidth;
MessageBlock last;
last.start = start;
last.end = isHeaderVec.size() - 1;
last.mine = mine;
blocks.push_back(last);
return blocks;
}
static void drawMessageScrollbar(OLEDDisplay *display, int visibleHeight, int totalHeight, int scrollOffset, int startY)
@@ -482,36 +358,43 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
constexpr int LEFT_MARGIN = 2;
constexpr int RIGHT_MARGIN = 2;
constexpr int SCROLLBAR_WIDTH = 3;
constexpr int BUBBLE_PAD_X = 3;
constexpr int BUBBLE_PAD_Y = 4;
constexpr int BUBBLE_RADIUS = 4;
constexpr int BUBBLE_MIN_W = 24;
constexpr int BUBBLE_TEXT_INDENT = 2;
const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN;
// Check if bubbles are enabled
const bool showBubbles = config.display.enable_message_bubbles;
const int textIndent = showBubbles ? (BUBBLE_PAD_X + BUBBLE_TEXT_INDENT) : LEFT_MARGIN;
// Derived widths
const int leftTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - (showBubbles ? (BUBBLE_PAD_X * 2) : 0);
const int rightTextWidth = SCREEN_WIDTH - LEFT_MARGIN - RIGHT_MARGIN - SCROLLBAR_WIDTH;
// Title string depending on mode
static char titleBuf[32];
const char *titleStr = "Messages";
char titleStr[48];
snprintf(titleStr, sizeof(titleStr), "Messages");
switch (currentMode) {
case ThreadMode::ALL:
titleStr = "Messages";
snprintf(titleStr, sizeof(titleStr), "Messages");
break;
case ThreadMode::CHANNEL: {
const char *cname = channels.getName(currentChannel);
if (cname && cname[0]) {
snprintf(titleBuf, sizeof(titleBuf), "#%s", cname);
snprintf(titleStr, sizeof(titleStr), "#%s", cname);
} else {
snprintf(titleBuf, sizeof(titleBuf), "Ch%d", currentChannel);
snprintf(titleStr, sizeof(titleStr), "Ch%d", currentChannel);
}
titleStr = titleBuf;
break;
}
case ThreadMode::DIRECT: {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentPeer);
if (node && node->has_user) {
snprintf(titleBuf, sizeof(titleBuf), "@%s", node->user.short_name);
if (node && node->has_user && node->user.short_name[0]) {
snprintf(titleStr, sizeof(titleStr), "@%s", node->user.short_name);
} else {
snprintf(titleBuf, sizeof(titleBuf), "@%08x", currentPeer);
snprintf(titleStr, sizeof(titleStr), "@%08x", currentPeer);
}
titleStr = titleBuf;
break;
}
}
@@ -539,6 +422,17 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
std::vector<bool> isMine; // track alignment
std::vector<bool> isHeader; // track header lines
std::vector<AckStatus> ackForLine;
// Hard limit on total cached lines to prevent unbounded growth from a single long message.
// Reserve to the actual cache cap up front, because a single message can expand to many more
// wrapped display lines than a small per-message estimate would predict. For a display
// rendering only ~5-30 lines at a time, caching more than this limit wastes heap. Stop
// appending once we reach MAX_CACHED_LINES to prevent a single message from blowing out the
// heap.
constexpr size_t MAX_CACHED_LINES = 100U; // ~5-6KB for std::string overhead on 32-bit (if each ~50-60 bytes avg)
allLines.reserve(MAX_CACHED_LINES);
isMine.reserve(MAX_CACHED_LINES);
isHeader.reserve(MAX_CACHED_LINES);
ackForLine.reserve(MAX_CACHED_LINES);
for (auto it = filtered.rbegin(); it != filtered.rend(); ++it) {
const auto &m = *it;
@@ -547,7 +441,28 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
char chanType[32] = "";
if (currentMode == ThreadMode::ALL) {
if (m.dest == NODENUM_BROADCAST) {
snprintf(chanType, sizeof(chanType), "#%s", channels.getName(m.channelIndex));
const char *name = channels.getName(m.channelIndex);
if (currentResolution == ScreenResolution::Low || currentResolution == ScreenResolution::UltraLow) {
if (strcmp(name, "ShortTurbo") == 0)
name = "ShortT";
else if (strcmp(name, "ShortSlow") == 0)
name = "ShortS";
else if (strcmp(name, "ShortFast") == 0)
name = "ShortF";
else if (strcmp(name, "MediumSlow") == 0)
name = "MedS";
else if (strcmp(name, "MediumFast") == 0)
name = "MedF";
else if (strcmp(name, "LongSlow") == 0)
name = "LongS";
else if (strcmp(name, "LongFast") == 0)
name = "LongF";
else if (strcmp(name, "LongTurbo") == 0)
name = "LongT";
else if (strcmp(name, "LongMod") == 0)
name = "LongM";
}
snprintf(chanType, sizeof(chanType), "#%s", name);
} else {
snprintf(chanType, sizeof(chanType), "(DM)");
}
@@ -597,44 +512,50 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(m.sender);
meshtastic_NodeInfoLite *node_recipient = nodeDB->getMeshNode(m.dest);
char senderBuf[48] = "";
char senderName[64] = "";
if (node && node->has_user) {
// Use long name if present
strncpy(senderBuf, node->user.long_name, sizeof(senderBuf) - 1);
senderBuf[sizeof(senderBuf) - 1] = '\0';
} else {
// No long/short name → show NodeID in parentheses
snprintf(senderBuf, sizeof(senderBuf), "(%08x)", m.sender);
if (node->user.long_name[0]) {
strncpy(senderName, node->user.long_name, sizeof(senderName) - 1);
} else if (node->user.short_name[0]) {
strncpy(senderName, node->user.short_name, sizeof(senderName) - 1);
}
senderName[sizeof(senderName) - 1] = '\0';
}
if (!senderName[0]) {
snprintf(senderName, sizeof(senderName), "(%08x)", m.sender);
}
// If this is *our own* message, override senderBuf to who the recipient was
// If this is *our own* message, override senderName to who the recipient was
bool mine = (m.sender == nodeDB->getNodeNum());
if (mine && node_recipient && node_recipient->has_user) {
strcpy(senderBuf, node_recipient->user.long_name);
if (node_recipient->user.long_name[0]) {
strncpy(senderName, node_recipient->user.long_name, sizeof(senderName) - 1);
senderName[sizeof(senderName) - 1] = '\0';
} else if (node_recipient->user.short_name[0]) {
strncpy(senderName, node_recipient->user.short_name, sizeof(senderName) - 1);
senderName[sizeof(senderName) - 1] = '\0';
}
}
// If recipient info is missing/empty, prefer a recipient identifier for outbound messages.
if (mine && (!node_recipient || !node_recipient->has_user ||
(!node_recipient->user.long_name[0] && !node_recipient->user.short_name[0]))) {
snprintf(senderName, sizeof(senderName), "(%08x)", m.dest);
}
// Shrink Sender name if needed
int availWidth = SCREEN_WIDTH - display->getStringWidth(timeBuf) - display->getStringWidth(chanType) -
display->getStringWidth(" @...") - 10;
int availWidth = (mine ? rightTextWidth : leftTextWidth) - display->getStringWidth(timeBuf) -
display->getStringWidth(chanType) - graphics::UIRenderer::measureStringWithEmotes(display, " @...");
if (availWidth < 0)
availWidth = 0;
size_t origLen = strlen(senderBuf);
while (senderBuf[0] && display->getStringWidth(senderBuf) > availWidth) {
senderBuf[strlen(senderBuf) - 1] = '\0';
}
// If we actually truncated, append "..."
if (strlen(senderBuf) < origLen) {
strcat(senderBuf, "...");
}
char truncatedSender[64];
graphics::UIRenderer::truncateStringWithEmotes(display, senderName, truncatedSender, sizeof(truncatedSender), availWidth);
// Final header line
char headerStr[96];
char headerStr[128];
if (mine) {
if (currentMode == ThreadMode::ALL) {
if (strcmp(chanType, "(DM)") == 0) {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, senderBuf);
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, truncatedSender);
} else {
snprintf(headerStr, sizeof(headerStr), "%s to %s", timeBuf, chanType);
}
@@ -642,11 +563,11 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
snprintf(headerStr, sizeof(headerStr), "%s", timeBuf);
}
} else {
snprintf(headerStr, sizeof(headerStr), "%s @%s %s", timeBuf, senderBuf, chanType);
snprintf(headerStr, sizeof(headerStr), chanType[0] ? "%s @%s %s" : "%s @%s", timeBuf, truncatedSender, chanType);
}
// Push header line
allLines.push_back(std::string(headerStr));
allLines.push_back(headerStr);
isMine.push_back(mine);
isHeader.push_back(true);
ackForLine.push_back(m.ackStatus);
@@ -655,18 +576,27 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
int wrapWidth = mine ? rightTextWidth : leftTextWidth;
std::vector<std::string> wrapped = generateLines(display, "", msgText, wrapWidth);
// Per-message wrap-line limit: even if wrapping produces many lines, cap them to prevent
// a single long message from consuming most or all of the cache.
constexpr size_t MAX_WRAPPED_LINES_PER_MSG = 20U;
size_t wrappedCount = 0;
for (auto &ln : wrapped) {
allLines.push_back(ln);
if (allLines.size() >= MAX_CACHED_LINES || wrappedCount >= MAX_WRAPPED_LINES_PER_MSG)
break; // Cache limit or per-message limit reached; stop adding lines from this message
allLines.emplace_back(std::move(ln));
isMine.push_back(mine);
isHeader.push_back(false);
ackForLine.push_back(AckStatus::NONE);
++wrappedCount;
}
}
// Cache lines and heights
cachedLines = allLines;
cachedLines.swap(allLines);
cachedHeights = calculateLineHeights(cachedLines, emotes, isHeader);
std::vector<MessageBlock> blocks = buildMessageBlocks(isHeader, isMine);
// Scrolling logic (unchanged)
int totalHeight = 0;
for (size_t i = 0; i < cachedHeights.size(); ++i)
@@ -714,27 +644,149 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
int finalScroll = (int)scrollY;
int yOffset = -finalScroll + getTextPositions(display)[1];
const int contentTop = getTextPositions(display)[1];
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);
std::vector<int> lineTop;
lineTop.resize(cachedLines.size());
{
int acc = 0;
for (size_t i = 0; i < cachedLines.size(); ++i) {
lineTop[i] = yOffset + acc;
acc += cachedHeights[i];
}
}
// Draw bubbles (only if enabled)
if (showBubbles) {
for (size_t bi = 0; bi < blocks.size(); ++bi) {
const auto &b = blocks[bi];
if (b.start >= cachedLines.size() || b.end >= cachedLines.size() || b.start > b.end)
continue;
int visualTop = lineTop[b.start];
int topY;
if (isHeader[b.start]) {
// Header start
constexpr int BUBBLE_PAD_TOP_HEADER = 1; // try 1 or 2
topY = visualTop - BUBBLE_PAD_TOP_HEADER;
} else {
// Body start
const bool thisLineHasEmote =
graphics::EmoteRenderer::analyzeLine(nullptr, cachedLines[b.start].c_str(), 0, emotes, numEmotes).hasEmote;
if (thisLineHasEmote) {
constexpr int EMOTE_PADDING_ABOVE = 4;
visualTop -= EMOTE_PADDING_ABOVE;
}
topY = visualTop - BUBBLE_PAD_Y;
}
int visualBottom = getDrawnLinePixelBottom(lineTop[b.end], cachedLines[b.end], isHeader[b.end]);
int bottomY = visualBottom + BUBBLE_PAD_Y;
if (bi + 1 < blocks.size()) {
int nextHeaderIndex = (int)blocks[bi + 1].start;
int nextTop = lineTop[nextHeaderIndex];
int maxBottom = nextTop - 1 - bubbleGapY;
if (bottomY > maxBottom)
bottomY = maxBottom;
}
if (bottomY <= topY + 2)
continue;
if (bottomY < contentTop || topY > contentBottom - 1)
continue;
int maxLineW = 0;
for (size_t i = b.start; i <= b.end; ++i) {
int w = 0;
if (isHeader[i]) {
w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
if (b.mine)
w += 12; // room for ACK/NACK/relay mark
} else {
w = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
}
if (w > maxLineW)
maxLineW = w;
}
int bubbleW = std::max(BUBBLE_MIN_W, maxLineW + (textIndent * 2));
int bubbleH = (bottomY - topY) + 1;
int bubbleX = 0;
if (b.mine) {
bubbleX = rightEdge - bubbleW;
} else {
bubbleX = x;
}
if (bubbleX < x)
bubbleX = x;
if (bubbleX + bubbleW > rightEdge)
bubbleW = std::max(1, rightEdge - bubbleX);
// Draw rounded rectangle bubble
if (bubbleW > BUBBLE_RADIUS * 2 && bubbleH > BUBBLE_RADIUS * 2) {
const int r = BUBBLE_RADIUS;
const int bx = bubbleX;
const int by = topY;
const int bw = bubbleW;
const int bh = bubbleH;
// 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
} else if (bubbleW > 1 && bubbleH > 1) {
// Fallback to simple rectangle for very small bubbles
display->drawRect(bubbleX, topY, bubbleW, bubbleH);
}
}
} // end if (showBubbles)
// Render visible lines
int lineY = yOffset;
for (size_t i = 0; i < cachedLines.size(); ++i) {
int lineY = yOffset;
for (size_t j = 0; j < i; ++j)
lineY += cachedHeights[j];
if (lineY > -cachedHeights[i] && lineY < scrollBottom) {
if (isHeader[i]) {
int w = display->getStringWidth(cachedLines[i].c_str());
int w = graphics::UIRenderer::measureStringWithEmotes(display, cachedLines[i].c_str());
int headerX;
if (isMine[i]) {
// push header left to avoid overlap with scrollbar
headerX = SCREEN_WIDTH - w - SCROLLBAR_WIDTH - RIGHT_MARGIN;
headerX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - w - (showBubbles ? textIndent : 0);
if (headerX < LEFT_MARGIN)
headerX = LEFT_MARGIN;
} else {
headerX = x;
headerX = x + textIndent;
}
graphics::UIRenderer::drawStringWithEmotes(display, headerX, lineY, cachedLines[i].c_str(), FONT_HEIGHT_SMALL, 1,
false);
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
int underlineW = w;
int maxW = rightEdge - headerX;
if (maxW < 0)
maxW = 0;
if (underlineW > maxW)
underlineW = maxW;
for (int px = 0; px < underlineW; ++px) {
display->setPixel(headerX + px, underlineY);
}
display->drawString(headerX, lineY, cachedLines[i].c_str());
// Draw ACK/NACK mark for our own messages
if (isMine[i]) {
@@ -753,32 +805,27 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
// AckStatus::NONE → show nothing
}
// Draw underline just under header text
int underlineY = lineY + FONT_HEIGHT_SMALL;
for (int px = 0; px < w; ++px) {
display->setPixel(headerX + px, underlineY);
}
} else {
// Render message line
if (isMine[i]) {
// Calculate actual rendered width including emotes
int renderedWidth = getRenderedLineWidth(display, cachedLines[i], emotes, numEmotes);
int rightX = SCREEN_WIDTH - renderedWidth - SCROLLBAR_WIDTH - RIGHT_MARGIN;
int rightX = (SCREEN_WIDTH - SCROLLBAR_WIDTH - RIGHT_MARGIN) - renderedWidth - (showBubbles ? textIndent : 0);
if (rightX < LEFT_MARGIN)
rightX = LEFT_MARGIN;
drawStringWithEmotes(display, rightX, lineY, cachedLines[i], emotes, numEmotes);
} else {
drawStringWithEmotes(display, x, lineY, cachedLines[i], emotes, numEmotes);
drawStringWithEmotes(display, x + textIndent, lineY, cachedLines[i], emotes, numEmotes);
}
}
}
lineY += cachedHeights[i];
}
int totalContentHeight = totalHeight;
int visibleHeight = usableHeight;
// Draw scrollbar
drawMessageScrollbar(display, visibleHeight, totalContentHeight, finalScroll, getTextPositions(display)[1]);
drawMessageScrollbar(display, usableHeight, totalHeight, finalScroll, getTextPositions(display)[1]);
graphics::drawCommonHeader(display, x, y, titleStr);
graphics::drawCommonFooter(display, x, y);
}
@@ -813,11 +860,7 @@ std::vector<std::string> generateLines(OLEDDisplay *display, const char *headerS
} else {
word += ch;
std::string test = line + word;
#if defined(OLED_UA) || defined(OLED_RU)
uint16_t strWidth = display->getStringWidth(test.c_str(), test.length(), true);
#else
uint16_t strWidth = display->getStringWidth(test.c_str());
#endif
uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
if (strWidth > textWidth) {
if (!line.empty())
lines.push_back(line);
@@ -841,39 +884,26 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
constexpr int HEADER_UNDERLINE_GAP = 0; // space between underline and first body line
constexpr int HEADER_UNDERLINE_PIX = 1; // underline thickness (1px row drawn)
constexpr int BODY_LINE_LEADING = -4; // default vertical leading for normal body lines
constexpr int MESSAGE_BLOCK_GAP = 4; // gap after a message block before a new header
constexpr int EMOTE_PADDING_ABOVE = 4; // space above emote line (added to line above)
constexpr int EMOTE_PADDING_BELOW = 3; // space below emote line (added to emote line)
std::vector<int> rowHeights;
rowHeights.reserve(lines.size());
std::vector<graphics::EmoteRenderer::LineMetrics> lineMetrics;
lineMetrics.reserve(lines.size());
for (const auto &line : lines) {
lineMetrics.push_back(graphics::EmoteRenderer::analyzeLine(nullptr, line, FONT_HEIGHT_SMALL, emotes, numEmotes));
}
for (size_t idx = 0; idx < lines.size(); ++idx) {
const auto &line = lines[idx];
const int baseHeight = FONT_HEIGHT_SMALL;
// Detect if THIS line or NEXT line contains an emote
bool hasEmote = false;
int tallestEmote = baseHeight;
for (int i = 0; i < numEmotes; ++i) {
if (line.find(emotes[i].label) != std::string::npos) {
hasEmote = true;
tallestEmote = std::max(tallestEmote, emotes[i].height);
}
}
bool nextHasEmote = false;
if (idx + 1 < lines.size()) {
for (int i = 0; i < numEmotes; ++i) {
if (lines[idx + 1].find(emotes[i].label) != std::string::npos) {
nextHasEmote = true;
break;
}
}
}
int lineHeight = baseHeight;
const int tallestEmote = lineMetrics[idx].tallestHeight;
const bool hasEmote = lineMetrics[idx].hasEmote;
const bool nextHasEmote = (idx + 1 < lines.size()) && lineMetrics[idx + 1].hasEmote;
if (isHeaderVec[idx]) {
// Header line spacing
lineHeight = baseHeight + HEADER_UNDERLINE_PIX + HEADER_UNDERLINE_GAP;
@@ -922,22 +952,22 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
// Banner logic
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet.from);
char longName[48] = "???";
if (node && node->user.long_name) {
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
char longName[64] = "?";
if (node && node->has_user) {
if (node->user.long_name[0]) {
strncpy(longName, node->user.long_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
} else if (node->user.short_name[0]) {
strncpy(longName, node->user.short_name, sizeof(longName) - 1);
longName[sizeof(longName) - 1] = '\0';
}
}
int availWidth = display->getWidth() - ((currentResolution == ScreenResolution::High) ? 40 : 20);
if (availWidth < 0)
availWidth = 0;
size_t origLen = strlen(longName);
while (longName[0] && display->getStringWidth(longName) > availWidth) {
longName[strlen(longName) - 1] = '\0';
}
if (strlen(longName) < origLen) {
strcat(longName, "...");
}
char truncatedLongName[64];
graphics::UIRenderer::truncateStringWithEmotes(display, longName, truncatedLongName, sizeof(truncatedLongName),
availWidth);
const char *msgRaw = reinterpret_cast<const char *>(packet.decoded.payload.bytes);
char banner[256];
@@ -955,8 +985,8 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
}
if (isAlert) {
if (longName && longName[0])
snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName);
if (truncatedLongName[0])
snprintf(banner, sizeof(banner), "Alert Received from\n%s", truncatedLongName);
else
strcpy(banner, "Alert Received");
} else {
@@ -964,11 +994,11 @@ void handleNewMessage(OLEDDisplay *display, const StoredMessage &sm, const mesht
if (isChannelMuted)
return;
if (longName && longName[0]) {
if (truncatedLongName[0]) {
if (currentResolution == ScreenResolution::UltraLow) {
strcpy(banner, "New Message");
} else {
snprintf(banner, sizeof(banner), "New Message from\n%s", longName);
snprintf(banner, sizeof(banner), "New Message from\n%s", truncatedLongName);
}
} else
strcpy(banner, "New Message");
@@ -1031,4 +1061,4 @@ void setThreadFor(const StoredMessage &sm, const meshtastic_MeshPacket &packet)
} // namespace MessageRenderer
} // namespace graphics
#endif
#endif
+57 -67
View File
@@ -79,13 +79,15 @@ void scrollDown()
// Utility Functions
// =============================
const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth)
{
static char nodeName[25]; // single static buffer we return
nodeName[0] = '\0';
(void)display;
(void)columnWidth;
auto writeFallbackId = [&] {
std::snprintf(nodeName, sizeof(nodeName), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
auto fallbackId = [&] {
char id[12];
std::snprintf(id, sizeof(id), "(%04X)", static_cast<uint16_t>(node ? (node->num & 0xFFFF) : 0));
return std::string(id);
};
// 1) Choose target candidate (long vs short) only if present
@@ -94,42 +96,10 @@ const char *getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node,
raw = config.display.use_long_node_name ? node->user.long_name : node->user.short_name;
}
// 2) Sanitize (empty if raw is null/empty)
std::string s = (raw && *raw) ? sanitizeString(raw) : std::string{};
// 3) Fallback if sanitize yields empty; otherwise copy safely (truncate if needed)
if (s.empty() || s == "¿" || s.find_first_not_of("¿") == std::string::npos) {
writeFallbackId();
} else {
// %.*s ensures null-termination and safe truncation to buffer size - 1
std::snprintf(nodeName, sizeof(nodeName), "%.*s", static_cast<int>(sizeof(nodeName) - 1), s.c_str());
}
// 4) Width-based truncation + ellipsis (long-name mode only)
if (config.display.use_long_node_name && display) {
int availWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
if (availWidth < 0)
availWidth = 0;
const size_t beforeLen = std::strlen(nodeName);
// Trim from the end until it fits or is empty
size_t len = beforeLen;
while (len && display->getStringWidth(nodeName) > availWidth) {
nodeName[--len] = '\0';
}
// If truncated, append "..." (respect buffer size)
if (len < beforeLen) {
// Make sure there's room for "..." and '\0'
const size_t capForText = sizeof(nodeName) - 1; // leaving space for '\0'
const size_t needed = 3; // "..."
if (len > capForText - needed) {
len = capForText - needed;
nodeName[len] = '\0';
}
std::strcat(nodeName, "...");
}
// 2) Preserve UTF-8 names so emotes can be detected and rendered.
std::string nodeName = (raw && *raw) ? std::string(raw) : std::string{};
if (nodeName.empty()) {
nodeName = fallbackId();
}
return nodeName;
@@ -163,6 +133,15 @@ const char *getCurrentModeTitle_Location(int screenWidth)
}
}
static int getNodeNameMaxWidth(int columnWidth, int baseWidth)
{
if (!config.display.use_long_node_name)
return baseWidth;
const int legacyLongNameWidth = columnWidth - ((currentResolution == ScreenResolution::High) ? 65 : 38);
return std::max(0, std::min(baseWidth, legacyLongNameWidth));
}
// Use dynamic timing based on mode
unsigned long getModeCycleIntervalMs()
{
@@ -171,7 +150,7 @@ unsigned long getModeCycleIntervalMs()
int calculateMaxScroll(int totalEntries, int visibleRows)
{
return std::max(0, (totalEntries - 1) / (visibleRows * 2));
return max(0, (totalEntries - 1) / (visibleRows * 2));
}
void drawColumnSeparator(OLEDDisplay *display, int16_t x, int16_t yStart, int16_t yEnd)
@@ -187,13 +166,12 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
if (totalEntries <= visibleNodeRows * columns)
return;
int scrollbarX = display->getWidth() - 2;
int scrollbarHeight = display->getHeight() - scrollStartY - 10;
int thumbHeight = std::max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries);
int perPage = visibleNodeRows * columns;
int maxScroll = std::max(0, (totalEntries - 1) / perPage);
int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) / std::max(1, maxScroll);
int thumbHeight = max(4, (scrollbarHeight * visibleNodeRows * columns) / totalEntries);
int thumbY = scrollStartY + (scrollIndex * (scrollbarHeight - thumbHeight)) /
max(1, max(0, (totalEntries - 1) / (visibleNodeRows * columns)));
int scrollbarX = display->getWidth() - 2;
for (int i = 0; i < thumbHeight; i++) {
display->setPixel(scrollbarX, thumbY + i);
}
@@ -206,10 +184,13 @@ void drawScrollbar(OLEDDisplay *display, int visibleNodeRows, int totalEntries,
void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16_t x, int16_t y, int columnWidth)
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth = columnWidth - 25;
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int timeOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 7 : 10) : (isLeftCol ? 3 : 7);
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char timeStr[10];
@@ -229,7 +210,7 @@ void drawEntryLastHeard(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawString(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -256,19 +237,22 @@ void drawEntryHopSignal(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth = columnWidth - 25;
int nameMaxWidth = getNodeNameMaxWidth(columnWidth, columnWidth - 25);
int barsOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 20 : 24) : (isLeftCol ? 15 : 19);
int hopOffset = (currentResolution == ScreenResolution::High) ? (isLeftCol ? 21 : 29) : (isLeftCol ? 13 : 17);
int barsXOffset = columnWidth - barsOffset;
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -313,9 +297,13 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
{
bool isLeftCol = (x < SCREEN_WIDTH / 2);
int nameMaxWidth =
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
: (isLeftCol ? 20 : 22)));
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
char distStr[10] = "";
@@ -369,7 +357,7 @@ void drawNodeDistance(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -415,14 +403,18 @@ void drawEntryCompass(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int16
// Adjust max text width depending on column and screen width
int nameMaxWidth =
columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28) : (isLeftCol ? 20 : 22));
getNodeNameMaxWidth(columnWidth, columnWidth - ((currentResolution == ScreenResolution::High) ? (isLeftCol ? 25 : 28)
: (isLeftCol ? 20 : 22)));
const char *nodeName = getSafeNodeName(display, node, columnWidth);
const int nameX = x + ((currentResolution == ScreenResolution::High) ? 6 : 3);
char nodeName[96];
UIRenderer::truncateStringWithEmotes(display, getSafeNodeName(display, node, columnWidth).c_str(), nodeName, sizeof(nodeName),
nameMaxWidth);
bool isMuted = (node->bitfield & NODEINFO_BITFIELD_IS_MUTED_MASK) != 0;
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->setFont(FONT_SMALL);
display->drawStringMaxWidth(x + ((currentResolution == ScreenResolution::High) ? 6 : 3), y, nameMaxWidth, nodeName);
UIRenderer::drawStringWithEmotes(display, nameX, y, nodeName, FONT_HEIGHT_SMALL, 1, false);
if (node->is_favorite) {
if (currentResolution == ScreenResolution::High) {
drawScaledXBitmap16x16(x, y + 6, smallbulletpoint_width, smallbulletpoint_height, smallbulletpoint, display);
@@ -556,13 +548,13 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
int maxScroll = 0;
if (perPage > 0) {
maxScroll = std::max(0, (totalEntries - 1) / perPage);
maxScroll = max(0, (totalEntries - 1) / perPage);
}
if (scrollIndex > maxScroll)
scrollIndex = maxScroll;
int startIndex = scrollIndex * visibleNodeRows * totalColumns;
int endIndex = std::min(startIndex + visibleNodeRows * totalColumns, totalEntries);
int endIndex = min(startIndex + visibleNodeRows * totalColumns, totalEntries);
int yOffset = 0;
int col = 0;
int lastNodeY = y;
@@ -580,7 +572,7 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
if (extras)
extras(display, node, xPos, yPos, columnWidth, heading, lat, lon);
lastNodeY = std::max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
lastNodeY = max(lastNodeY, yPos + FONT_HEIGHT_SMALL);
yOffset += rowYOffset;
shownCount++;
rowCount++;
@@ -613,13 +605,11 @@ void drawNodeListScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t
if (millis() - popupTime < POPUP_DURATION_MS) {
popupTotal = totalEntries;
int perPage = visibleNodeRows * totalColumns;
popupStart = startIndex + 1;
popupEnd = std::min(startIndex + perPage, totalEntries);
popupEnd = min(startIndex + perPage, totalEntries);
popupPage = (scrollIndex + 1);
popupMaxPage = std::max(1, (totalEntries + perPage - 1) / perPage);
popupMaxPage = max(1, (totalEntries + perPage - 1) / perPage);
char buf[32];
snprintf(buf, sizeof(buf), "%d-%d/%d Pg %d/%d", popupStart, popupEnd, popupTotal, popupPage, popupMaxPage);
@@ -831,4 +821,4 @@ void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields
} // namespace NodeListRenderer
} // namespace graphics
#endif
#endif
+2 -1
View File
@@ -4,6 +4,7 @@
#include "mesh/generated/meshtastic/mesh.pb.h"
#include <OLEDDisplay.h>
#include <OLEDDisplayUi.h>
#include <string>
namespace graphics
{
@@ -56,7 +57,7 @@ void drawNodeListWithCompasses(OLEDDisplay *display, OLEDDisplayUiState *state,
// Utility functions
const char *getCurrentModeTitle_Nodes(int screenWidth);
const char *getCurrentModeTitle_Location(int screenWidth);
const char *getSafeNodeName(meshtastic_NodeInfoLite *node, int columnWidth);
std::string getSafeNodeName(OLEDDisplay *display, meshtastic_NodeInfoLite *node, int columnWidth);
void drawColumns(OLEDDisplay *display, int16_t x, int16_t y, const char **fields);
// Scrolling controls
+59 -21
View File
@@ -4,6 +4,7 @@
#include "DisplayFormatters.h"
#include "NodeDB.h"
#include "NotificationRenderer.h"
#include "UIRenderer.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
@@ -43,7 +44,7 @@ InputEvent NotificationRenderer::inEvent;
int8_t NotificationRenderer::curSelected = 0;
char NotificationRenderer::alertBannerMessage[256] = {0};
uint32_t NotificationRenderer::alertBannerUntil = 0; // 0 is a special case meaning forever
uint8_t NotificationRenderer::alertBannerOptions = 0; // last x lines are seelctable options
uint8_t NotificationRenderer::alertBannerOptions = 0; // last x lines are selectable options
const char **NotificationRenderer::optionsArrayPtr = nullptr;
const int *NotificationRenderer::optionsEnumPtr = nullptr;
std::function<void(int)> NotificationRenderer::alertBannerCallback = NULL;
@@ -95,7 +96,7 @@ void NotificationRenderer::resetBanner()
inEvent.inputEvent = INPUT_BROKER_NONE;
inEvent.kbchar = 0;
curSelected = 0;
alertBannerOptions = 0; // last x lines are seelctable options
alertBannerOptions = 0; // last x lines are selectable options
optionsArrayPtr = nullptr;
optionsEnumPtr = nullptr;
alertBannerCallback = NULL;
@@ -299,7 +300,7 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
for (int i = 0; i < lineCount; i++) {
linePointers[i] = lineStarts[i];
}
char scratchLineBuffer[visibleTotalLines - lineCount][40];
char scratchLineBuffer[visibleTotalLines - lineCount][64];
uint8_t firstOptionToShow = 0;
if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) {
@@ -312,28 +313,47 @@ void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiSta
}
int scratchLineNum = 0;
for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) {
char temp_name[16] = {0};
if (nodeDB->getMeshNodeByIndex(i + 1)->has_user) {
std::string sanitized = sanitizeString(nodeDB->getMeshNodeByIndex(i + 1)->user.long_name);
strncpy(temp_name, sanitized.c_str(), sizeof(temp_name) - 1);
char tempName[48] = {0};
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i + 1);
if (node && node->has_user) {
const char *rawName = nullptr;
if (node->user.long_name[0]) {
rawName = node->user.long_name;
} else if (node->user.short_name[0]) {
rawName = node->user.short_name;
}
if (rawName) {
const int arrowWidth = (currentResolution == ScreenResolution::High)
? UIRenderer::measureStringWithEmotes(display, "> <")
: UIRenderer::measureStringWithEmotes(display, "><");
const int maxTextWidth = std::max(0, display->getWidth() - 28 - arrowWidth);
UIRenderer::truncateStringWithEmotes(display, rawName, tempName, sizeof(tempName), maxTextWidth);
}
} else {
snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF));
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
}
if (!tempName[0]) {
snprintf(tempName, sizeof(tempName), "(%04X)", (uint16_t)(node ? (node->num & 0xFFFF) : 0));
}
if (i == curSelected) {
selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num;
selectedNodenum = node ? node->num : 0;
if (currentResolution == ScreenResolution::High) {
strncpy(scratchLineBuffer[scratchLineNum], "> ", 3);
strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36);
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3);
strncpy(scratchLineBuffer[scratchLineNum] + 2, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 3);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
strncpy(scratchLineBuffer[scratchLineNum] + used, " <", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
} else {
strncpy(scratchLineBuffer[scratchLineNum], ">", 2);
strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37);
strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2);
strncpy(scratchLineBuffer[scratchLineNum] + 1, tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 2);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
const size_t used = strnlen(scratchLineBuffer[scratchLineNum], sizeof(scratchLineBuffer[scratchLineNum]) - 1);
strncpy(scratchLineBuffer[scratchLineNum] + used, "<", sizeof(scratchLineBuffer[scratchLineNum]) - used - 1);
}
scratchLineBuffer[scratchLineNum][39] = '\0';
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
} else {
strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39);
scratchLineBuffer[scratchLineNum][39] = '\0';
strncpy(scratchLineBuffer[scratchLineNum], tempName, sizeof(scratchLineBuffer[scratchLineNum]) - 1);
scratchLineBuffer[scratchLineNum][sizeof(scratchLineBuffer[scratchLineNum]) - 1] = '\0';
}
linePointers[linesShown] = scratchLineBuffer[scratchLineNum++];
}
@@ -501,7 +521,13 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
else // if the newline wasn't found, then pull string length from strlen
lineLengths[lineCount] = strlen(lines[lineCount]);
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
if (current_notification_type == notificationTypeEnum::node_picker) {
char measureBuffer[64] = {0};
strncpy(measureBuffer, lines[lineCount], std::min<size_t>(lineLengths[lineCount], sizeof(measureBuffer) - 1));
lineWidths[lineCount] = UIRenderer::measureStringWithEmotes(display, measureBuffer);
} else {
lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true);
}
// Consider extra width for signal bars on lines that contain "Signal:"
uint16_t potentialWidth = lineWidths[lineCount];
@@ -607,7 +633,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset);
display->setColor(BLACK);
int yOffset = 3;
display->drawString(textX, lineY - yOffset, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY - yOffset, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY - yOffset, lineBuffer);
}
display->setColor(WHITE);
lineY += (effectiveLineHeight - 2 - background_yOffset);
} else {
@@ -626,7 +656,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
int totalWidth = textWidth + barsWidth;
int groupStartX = boxLeft + (boxWidth - totalWidth) / 2;
display->drawString(groupStartX, lineY, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, groupStartX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(groupStartX, lineY, lineBuffer);
}
int baseX = groupStartX + textWidth + gap;
int baseY = lineY + effectiveLineHeight - 1;
@@ -642,7 +676,11 @@ void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplay
}
}
} else {
display->drawString(textX, lineY, lineBuffer);
if (current_notification_type == notificationTypeEnum::node_picker) {
UIRenderer::drawStringWithEmotes(display, textX, lineY, lineBuffer, FONT_HEIGHT_SMALL, 1, false);
} else {
display->drawString(textX, lineY, lineBuffer);
}
}
lineY += (effectiveLineHeight);
}
@@ -781,4 +819,4 @@ void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title,
}
} // namespace graphics
#endif
#endif
+1 -1
View File
@@ -22,7 +22,7 @@ class NotificationRenderer
static uint32_t alertBannerUntil; // 0 is a special case meaning forever
static const char **optionsArrayPtr;
static const int *optionsEnumPtr;
static uint8_t alertBannerOptions; // last x lines are seelctable options
static uint8_t alertBannerOptions; // last x lines are selectable options
static std::function<void(int)> alertBannerCallback;
static uint32_t numDigits;
static uint32_t currentNumber;
+293 -85
View File
@@ -2,11 +2,13 @@
#if HAS_SCREEN
#include "CompassRenderer.h"
#include "GPSStatus.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "NodeListRenderer.h"
#include "UIRenderer.h"
#include "airtime.h"
#include "gps/GeoCoord.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/TimeFormatters.h"
#include "graphics/images.h"
@@ -287,7 +289,8 @@ void UIRenderer::drawNodes(OLEDDisplay *display, int16_t x, int16_t y, const mes
// **********************
// * Favorite Node Info *
// **********************
void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y)
// cppcheck-suppress constParameterPointer; signature must match FrameCallback typedef from OLEDDisplayUi library
void UIRenderer::drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
{
if (favoritedNodes.empty())
return;
@@ -311,9 +314,9 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
#endif
currentFavoriteNodeNum = node->num;
// === Create the shortName and title string ===
const char *shortName = (node->has_user && haveGlyphs(node->user.short_name)) ? node->user.short_name : "Node";
char titlestr[32] = {0};
snprintf(titlestr, sizeof(titlestr), "Fav: %s", shortName);
const char *shortName = (node->has_user && node->user.short_name[0]) ? node->user.short_name : "Node";
char titlestr[40];
snprintf(titlestr, sizeof(titlestr), "*%s*", shortName);
// === Draw battery/time/mail header (common across screens) ===
graphics::drawCommonHeader(display, x, y, titlestr);
@@ -326,7 +329,6 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
// List of available macro Y positions in order, from top to bottom.
int line = 1; // which slot to use next
std::string usernameStr;
// === 1. Long Name (always try to show first) ===
const char *username;
if (currentResolution == ScreenResolution::UltraLow) {
@@ -336,40 +338,167 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
}
if (username) {
usernameStr = sanitizeString(username); // Sanitize the incoming long_name just in case
// Print node's long name (e.g. "Backpack Node")
display->drawString(x, getTextPositions(display)[line++], usernameStr.c_str());
UIRenderer::drawStringWithEmotes(display, x, getTextPositions(display)[line++], username, FONT_HEIGHT_SMALL, 1, false);
}
// === 2. Signal and Hops (combined on one line, if available) ===
// If both are present: "Sig: 97% [2hops]"
// If only one: show only that one
char signalHopsStr[32] = "";
bool haveSignal = false;
int percentSignal = clamp((int)((node->snr + 10) * 5), 0, 100);
int bars = 0;
// Always use "Sig" for the label
const char *signalLabel = " Sig";
// Helper to get SNR limit based on modem preset
auto getSnrLimit = [](meshtastic_Config_LoRaConfig_ModemPreset preset) -> float {
switch (preset) {
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE:
case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST:
return -6.0f;
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST:
return -5.5f;
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW:
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST:
case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO:
return -4.5f;
default:
return -6.0f;
}
};
// Calculate signal grade using modem preset and SNR only
float snrLimit = getSnrLimit(config.lora.modem_preset);
float snr = node->snr;
// Determine signal quality label and bars using SNR-only grading
const char *qualityLabel = nullptr;
if (snr > snrLimit + 10) {
qualityLabel = "Good";
bars = 4;
} else if (snr > snrLimit + 6) {
qualityLabel = "Good";
bars = 3;
} else if (snr > snrLimit + 2) {
qualityLabel = "Good";
bars = 2;
} else if (snr > snrLimit - 4) {
qualityLabel = "Fair";
bars = 1;
} else {
qualityLabel = "Bad";
bars = 1;
}
// Add extra spacing on the left if we have an API connection to account for the common footer icons
const char *leftSideSpacing =
graphics::isAPIConnected(service->api_state) ? (currentResolution == ScreenResolution::High ? " " : " ") : " ";
// --- Build the Signal/Hops line ---
// If SNR looks reasonable, show signal
if ((int)((node->snr + 10) * 5) >= 0 && node->snr > -100) {
snprintf(signalHopsStr, sizeof(signalHopsStr), "%s: %d%%", signalLabel, percentSignal);
// Only show signal if we have valid SNR
if (snr > -100 && snr != 0) {
snprintf(signalHopsStr, sizeof(signalHopsStr), "%sSig:%s", leftSideSpacing, qualityLabel);
haveSignal = true;
}
// If hops is valid (>0), show right after signal
if (node->hops_away > 0) {
size_t len = strlen(signalHopsStr);
// Decide between "1 Hop" and "N Hops"
if (haveSignal) {
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [%d %s]", node->hops_away,
(node->hops_away == 1 ? "Hop" : "Hops"));
snprintf(signalHopsStr + len, sizeof(signalHopsStr) - len, " [#]");
} else {
snprintf(signalHopsStr, sizeof(signalHopsStr), "[%d %s]", node->hops_away, (node->hops_away == 1 ? "Hop" : "Hops"));
snprintf(signalHopsStr, sizeof(signalHopsStr), "[#]");
}
}
if (signalHopsStr[0] && line < 5) {
display->drawString(x, getTextPositions(display)[line++], signalHopsStr);
if (signalHopsStr[0]) {
int yPos = getTextPositions(display)[line++];
int curX = x;
// Split combined string into signal text and hop suffix
char sigPart[20] = "";
const char *hopPart = nullptr;
char *bracket = strchr(signalHopsStr, '[');
if (bracket) {
size_t n = (size_t)(bracket - signalHopsStr);
if (n >= sizeof(sigPart))
n = sizeof(sigPart) - 1;
memcpy(sigPart, signalHopsStr, n);
sigPart[n] = '\0';
// Trim trailing spaces
while (strlen(sigPart) && sigPart[strlen(sigPart) - 1] == ' ') {
sigPart[strlen(sigPart) - 1] = '\0';
}
hopPart = bracket; // "[n Hop(s)]"
} else {
strncpy(sigPart, signalHopsStr, sizeof(sigPart) - 1);
sigPart[sizeof(sigPart) - 1] = '\0';
}
// Draw signal quality text
display->drawString(curX, yPos, sigPart);
curX += display->getStringWidth(sigPart) + 4;
// Draw signal bars (skip on UltraLow, text only)
if (currentResolution != ScreenResolution::UltraLow && haveSignal && bars > 0) {
const int kMaxBars = 4;
if (bars < 1)
bars = 1;
if (bars > kMaxBars)
bars = kMaxBars;
int barX = curX;
const bool hi = (currentResolution == ScreenResolution::High);
int barWidth = hi ? 2 : 1;
int barGap = hi ? 2 : 1;
int maxBarHeight = FONT_HEIGHT_SMALL - 7;
if (!hi)
maxBarHeight -= 1;
int barY = yPos + (FONT_HEIGHT_SMALL - maxBarHeight) / 2;
for (int bi = 0; bi < kMaxBars; bi++) {
int barHeight = maxBarHeight * (bi + 1) / kMaxBars;
if (barHeight < 2)
barHeight = 2;
int bx = barX + bi * (barWidth + barGap);
int by = barY + maxBarHeight - barHeight;
if (bi < bars) {
display->fillRect(bx, by, barWidth, barHeight);
} else {
int baseY = barY + maxBarHeight - 1;
display->drawHorizontalLine(bx, baseY, barWidth);
}
}
curX += (kMaxBars * barWidth) + ((kMaxBars - 1) * barGap) + 2;
}
// Draw hops AFTER the bars as: [ number + hop icon ]
if (hopPart && node->hops_away > 0) {
// open bracket
display->drawString(curX, yPos, "[");
curX += display->getStringWidth("[") + 1;
// 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;
// closing bracket
display->drawString(curX, yPos, "]");
}
}
// === 3. Heard (last seen, skip if node never seen) ===
@@ -377,8 +506,8 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
uint32_t seconds = sinceLastSeen(node);
if (seconds != 0 && seconds != UINT32_MAX) {
uint32_t minutes = seconds / 60, hours = minutes / 60, days = hours / 24;
// Format as "Heard: Xm ago", "Heard: Xh ago", or "Heard: Xd ago"
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard: ?" : " Heard: %d%c ago"),
// Format as "Heard:Xm ago", "Heard:Xh ago", or "Heard:Xd ago"
snprintf(seenStr, sizeof(seenStr), (days > 365 ? " Heard:?" : "%sHeard:%d%c ago"), leftSideSpacing,
(days ? days
: hours ? hours
: minutes),
@@ -386,16 +515,18 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
: hours ? 'h'
: 'm'));
}
if (seenStr[0] && line < 5) {
if (seenStr[0]) {
display->drawString(x, getTextPositions(display)[line++], seenStr);
}
#if !defined(M5STACK_UNITC6L)
// === 4. Uptime (only show if metric is present) ===
char uptimeStr[32] = "";
if (node->has_device_metrics && node->device_metrics.has_uptime_seconds) {
getUptimeStr(node->device_metrics.uptime_seconds * 1000, " Up", uptimeStr, sizeof(uptimeStr));
char upPrefix[12]; // enough for leftSideSpacing + "Up:"
snprintf(upPrefix, sizeof(upPrefix), "%sUp:", leftSideSpacing);
getUptimeStr(node->device_metrics.uptime_seconds * 1000, upPrefix, uptimeStr, sizeof(uptimeStr));
}
if (uptimeStr[0] && line < 5) {
if (uptimeStr[0]) {
display->drawString(x, getTextPositions(display)[line++], uptimeStr);
}
@@ -422,16 +553,16 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
if (miles < 0.1) {
int feet = (int)(miles * 5280);
if (feet > 0 && feet < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dft", feet);
snprintf(distStr, sizeof(distStr), "%sDistance:%dft", leftSideSpacing, feet);
haveDistance = true;
} else if (feet >= 1000) {
snprintf(distStr, sizeof(distStr), " Distance: ¼mi");
snprintf(distStr, sizeof(distStr), "%sDistance:¼mi", leftSideSpacing);
haveDistance = true;
}
} else {
int roundedMiles = (int)(miles + 0.5);
if (roundedMiles > 0 && roundedMiles < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dmi", roundedMiles);
snprintf(distStr, sizeof(distStr), "%sDistance:%dmi", leftSideSpacing, roundedMiles);
haveDistance = true;
}
}
@@ -439,26 +570,74 @@ void UIRenderer::drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *st
if (distanceKm < 1.0) {
int meters = (int)(distanceKm * 1000);
if (meters > 0 && meters < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dm", meters);
snprintf(distStr, sizeof(distStr), "%sDistance:%dm", leftSideSpacing, meters);
haveDistance = true;
} else if (meters >= 1000) {
snprintf(distStr, sizeof(distStr), " Distance: 1km");
snprintf(distStr, sizeof(distStr), "%sDistance:1km", leftSideSpacing);
haveDistance = true;
}
} else {
int km = (int)(distanceKm + 0.5);
if (km > 0 && km < 1000) {
snprintf(distStr, sizeof(distStr), " Distance: %dkm", km);
snprintf(distStr, sizeof(distStr), "%sDistance:%dkm", leftSideSpacing, km);
haveDistance = true;
}
}
}
}
// Only display if we actually have a value!
if (haveDistance && distStr[0] && line < 5) {
if (haveDistance && distStr[0]) {
display->drawString(x, getTextPositions(display)[line++], distStr);
}
// === 6. Battery after Distance line, otherwise next available line ===
char batLine[32] = "";
bool haveBatLine = false;
if (node->has_device_metrics) {
bool hasPct = node->device_metrics.has_battery_level;
bool hasVolt = node->device_metrics.has_voltage && node->device_metrics.voltage > 0.001f;
int pct = 0;
float volt = 0.0f;
if (hasPct) {
pct = (int)node->device_metrics.battery_level;
}
if (hasVolt) {
volt = node->device_metrics.voltage;
}
if (hasPct && pct > 0 && pct <= 100) {
// Normal battery percentage
if (hasVolt) {
snprintf(batLine, sizeof(batLine), "%sBat:%d%% (%.2fV)", leftSideSpacing, pct, volt);
} else {
snprintf(batLine, sizeof(batLine), "%sBat:%d%%", leftSideSpacing, pct);
}
haveBatLine = true;
} else if (hasPct && pct > 100) {
// Plugged in
if (hasVolt) {
snprintf(batLine, sizeof(batLine), "%sPlugged In (%.2fV)", leftSideSpacing, volt);
} else {
snprintf(batLine, sizeof(batLine), "%sPlugged In", leftSideSpacing);
}
haveBatLine = true;
} else if (!hasPct && hasVolt) {
// Voltage only
snprintf(batLine, sizeof(batLine), "%sBat:%.2fV", leftSideSpacing, volt);
haveBatLine = true;
}
}
const int maxTextLines = (currentResolution == ScreenResolution::High) ? 6 : 5;
// Only draw battery if it fits within the allowed lines
if (haveBatLine && line <= maxTextLines) {
display->drawString(x, getTextPositions(display)[line++], batLine);
}
// --- Compass Rendering: landscape (wide) screens use the original side-aligned logic ---
if (SCREEN_WIDTH > SCREEN_HEIGHT) {
bool showCompass = false;
@@ -593,7 +772,7 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
}
char uptimeStr[32] = "";
if (currentResolution != ScreenResolution::UltraLow) {
getUptimeStr(millis(), "Up", uptimeStr, sizeof(uptimeStr));
getUptimeStr(millis(), "Up: ", uptimeStr, sizeof(uptimeStr));
}
display->drawString(SCREEN_WIDTH - display->getStringWidth(uptimeStr), getTextPositions(display)[line++], uptimeStr);
@@ -622,14 +801,12 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
// === Node Identity ===
int textWidth = 0;
int nameX = 0;
char shortnameble[35];
snprintf(shortnameble, sizeof(shortnameble), "%s",
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
const char *shortName = owner.short_name ? owner.short_name : "";
// === ShortName Centered ===
textWidth = display->getStringWidth(shortnameble);
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1, false);
#else
if (powerStatus->getHasBattery()) {
char batStr[20];
@@ -724,36 +901,36 @@ void UIRenderer::drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *sta
int textWidth = 0;
int nameX = 0;
int yOffset = (currentResolution == ScreenResolution::High) ? 0 : 5;
std::string longNameStr;
if (ourNode && ourNode->has_user && strlen(ourNode->user.long_name) > 0) {
longNameStr = sanitizeString(ourNode->user.long_name);
const char *longName = (ourNode && ourNode->has_user && ourNode->user.long_name[0]) ? ourNode->user.long_name : "";
const char *shortName = owner.short_name ? owner.short_name : "";
char combinedName[96];
if (longName[0] && shortName[0]) {
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longName, shortName);
} else if (longName[0]) {
strncpy(combinedName, longName, sizeof(combinedName) - 1);
combinedName[sizeof(combinedName) - 1] = '\0';
} else {
strncpy(combinedName, shortName, sizeof(combinedName) - 1);
combinedName[sizeof(combinedName) - 1] = '\0';
}
char shortnameble[35];
snprintf(shortnameble, sizeof(shortnameble), "%s",
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
char combinedName[50];
snprintf(combinedName, sizeof(combinedName), "%s (%s)", longNameStr.empty() ? "" : longNameStr.c_str(), shortnameble);
if (SCREEN_WIDTH - (display->getStringWidth(combinedName)) > 10) {
size_t len = strlen(combinedName);
if (len >= 3 && strcmp(combinedName + len - 3, " ()") == 0) {
combinedName[len - 3] = '\0'; // Remove the last three characters
}
textWidth = display->getStringWidth(combinedName);
if (SCREEN_WIDTH - UIRenderer::measureStringWithEmotes(display, combinedName) > 10) {
textWidth = UIRenderer::measureStringWithEmotes(display, combinedName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(
nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset, combinedName);
UIRenderer::drawStringWithEmotes(
display, nameX, ((rows == 4) ? getTextPositions(display)[line++] : getTextPositions(display)[line++]) + yOffset,
combinedName, FONT_HEIGHT_SMALL, 1, false);
} else {
// === LongName Centered ===
textWidth = display->getStringWidth(longNameStr.c_str());
textWidth = UIRenderer::measureStringWithEmotes(display, longName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], longNameStr.c_str());
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], longName, FONT_HEIGHT_SMALL, 1,
false);
// === ShortName Centered ===
textWidth = display->getStringWidth(shortnameble);
textWidth = UIRenderer::measureStringWithEmotes(display, shortName);
nameX = (SCREEN_WIDTH - textWidth) / 2;
display->drawString(nameX, getTextPositions(display)[line++], shortnameble);
UIRenderer::drawStringWithEmotes(display, nameX, getTextPositions(display)[line++], shortName, FONT_HEIGHT_SMALL, 1,
false);
}
#endif
graphics::drawCommonFooter(display, x, y);
@@ -865,12 +1042,12 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
display->setTextAlignment(TEXT_ALIGN_LEFT);
const char *pauseText = "Screen Paused";
const char *idText = owner.short_name;
const bool useId = haveGlyphs(idText);
const bool useId = (idText && idText[0]);
constexpr uint8_t padding = 2;
constexpr uint8_t dividerGap = 1;
// Text widths
const uint16_t idTextWidth = display->getStringWidth(idText, strlen(idText), true);
const uint16_t idTextWidth = useId ? UIRenderer::measureStringWithEmotes(display, idText) : 0;
const uint16_t pauseTextWidth = display->getStringWidth(pauseText, strlen(pauseText));
const uint16_t boxWidth = padding + (useId ? idTextWidth + padding : 0) + pauseTextWidth + padding;
const uint16_t boxHeight = FONT_HEIGHT_SMALL + (padding * 2);
@@ -895,7 +1072,7 @@ void UIRenderer::drawScreensaverOverlay(OLEDDisplay *display, OLEDDisplayUiState
// Draw: text
if (useId)
display->drawString(idTextLeft, idTextTop, idText);
UIRenderer::drawStringWithEmotes(display, idTextLeft, idTextTop, idText, FONT_HEIGHT_SMALL, 1, false);
display->drawString(pauseTextLeft, pauseTextTop, pauseText);
display->drawString(pauseTextLeft + 1, pauseTextTop, pauseText); // Faux bold
@@ -928,11 +1105,16 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(msgX, msgY, upperMsg);
}
// Draw version and short name in bottom middle
char buf[25];
snprintf(buf, sizeof(buf), "%s %s", xstr(APP_VERSION_SHORT),
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
display->drawString(x + getStringCenteredX(buf), y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, buf);
char footer[64];
if (owner.short_name && owner.short_name[0]) {
snprintf(footer, sizeof(footer), "%s %s", xstr(APP_VERSION_SHORT), owner.short_name);
} else {
snprintf(footer, sizeof(footer), "%s", xstr(APP_VERSION_SHORT));
}
int footerW = UIRenderer::measureStringWithEmotes(display, footer);
int footerX = x + ((SCREEN_WIDTH - footerW) / 2);
UIRenderer::drawStringWithEmotes(display, footerX, y + SCREEN_HEIGHT - FONT_HEIGHT_MEDIUM, footer, FONT_HEIGHT_SMALL, 1,
false);
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -950,12 +1132,15 @@ void UIRenderer::drawIconScreen(const char *upperMsg, OLEDDisplay *display, OLED
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and short name in upper right
char buf[25];
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT),
graphics::UIRenderer::haveGlyphs(owner.short_name) ? owner.short_name : "");
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
const char *version = xstr(APP_VERSION_SHORT);
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
display->drawString(versionX, y + 0, version);
if (owner.short_name && owner.short_name[0]) {
const char *shortName = owner.short_name;
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
int shortNameX = x + SCREEN_WIDTH - shortNameW;
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
}
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -984,7 +1169,6 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
config.display.heading_bold = false;
const char *displayLine = ""; // Initialize to empty string by default
meshtastic_NodeInfoLite *ourNode = nodeDB->getMeshNode(nodeDB->getNodeNum());
if (config.position.gps_mode != meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
if (config.position.fixed_position) {
@@ -1029,10 +1213,10 @@ void UIRenderer::drawCompassAndLocationScreen(OLEDDisplay *display, OLEDDisplayU
char uptimeStr[32];
#if defined(USE_EINK)
// E-Ink: skip seconds, show only days/hours/mins
getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), false);
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), false);
#else
// Non E-Ink: include seconds where useful
getUptimeStr(delta, "Last", uptimeStr, sizeof(uptimeStr), true);
getUptimeStr(delta, "Last: ", uptimeStr, sizeof(uptimeStr), true);
#endif
display->drawString(0, getTextPositions(display)[line++], uptimeStr);
@@ -1186,11 +1370,15 @@ void UIRenderer::drawOEMIconScreen(const char *upperMsg, OLEDDisplay *display, O
display->drawString(x + 0, y + 0, upperMsg);
// Draw version and shortname in upper right
char buf[25];
snprintf(buf, sizeof(buf), "%s\n%s", xstr(APP_VERSION_SHORT), haveGlyphs(owner.short_name) ? owner.short_name : "");
display->setTextAlignment(TEXT_ALIGN_RIGHT);
display->drawString(x + SCREEN_WIDTH, y + 0, buf);
const char *version = xstr(APP_VERSION_SHORT);
int versionX = x + SCREEN_WIDTH - display->getStringWidth(version);
display->drawString(versionX, y + 0, version);
if (owner.short_name && owner.short_name[0]) {
const char *shortName = owner.short_name;
int shortNameW = UIRenderer::measureStringWithEmotes(display, shortName);
int shortNameX = x + SCREEN_WIDTH - shortNameW;
UIRenderer::drawStringWithEmotes(display, shortNameX, y + FONT_HEIGHT_SMALL, shortName, FONT_HEIGHT_SMALL, 1, false);
}
screen->forceDisplay();
display->setTextAlignment(TEXT_ALIGN_LEFT); // Restore left align, just to be kind to any other unsuspecting code
@@ -1210,6 +1398,7 @@ static int8_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;
@@ -1378,6 +1567,25 @@ std::string UIRenderer::drawTimeDelta(uint32_t days, uint32_t hours, uint32_t mi
return uptime;
}
int UIRenderer::measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing)
{
return graphics::EmoteRenderer::measureStringWithEmotes(display, line, graphics::emotes, graphics::numEmotes, emoteSpacing);
}
size_t UIRenderer::truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis, int emoteSpacing)
{
return graphics::EmoteRenderer::truncateToWidth(display, line, out, outSize, maxWidth, ellipsis, graphics::emotes,
graphics::numEmotes, emoteSpacing);
}
void UIRenderer::drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing,
bool fauxBold)
{
graphics::EmoteRenderer::drawStringWithEmotes(display, x, y, line, fontHeight, graphics::emotes, graphics::numEmotes,
emoteSpacing, fauxBold);
}
} // namespace graphics
#endif // HAS_SCREEN
+24 -1
View File
@@ -1,6 +1,7 @@
#pragma once
#include "NodeDB.h"
#include "graphics/EmoteRenderer.h"
#include "graphics/Screen.h"
#include "graphics/emotes.h"
#include <OLEDDisplay.h>
@@ -49,7 +50,7 @@ class UIRenderer
// Navigation bar overlay
static void drawNavigationBar(OLEDDisplay *display, OLEDDisplayUiState *state);
static void drawNodeInfo(OLEDDisplay *display, const OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawNodeInfo(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
static void drawDeviceFocused(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
@@ -80,6 +81,28 @@ class UIRenderer
static std::string drawTimeDelta(uint32_t days, uint32_t hours, uint32_t minutes, uint32_t seconds);
static int formatDateTime(char *buffer, size_t bufferSize, uint32_t rtc_sec, OLEDDisplay *display, bool showTime);
// Shared BaseUI emote helpers.
static int measureStringWithEmotes(OLEDDisplay *display, const char *line, int emoteSpacing = 1);
static inline int measureStringWithEmotes(OLEDDisplay *display, const std::string &line, int emoteSpacing = 1)
{
return measureStringWithEmotes(display, line.c_str(), emoteSpacing);
}
static size_t truncateStringWithEmotes(OLEDDisplay *display, const char *line, char *out, size_t outSize, int maxWidth,
const char *ellipsis = "...", int emoteSpacing = 1);
static inline std::string truncateStringWithEmotes(OLEDDisplay *display, const std::string &line, int maxWidth,
const std::string &ellipsis = "...", int emoteSpacing = 1)
{
return graphics::EmoteRenderer::truncateToWidth(display, line, maxWidth, ellipsis, graphics::emotes, graphics::numEmotes,
emoteSpacing);
}
static void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const char *line, int fontHeight, int emoteSpacing = 1,
bool fauxBold = true);
static inline void drawStringWithEmotes(OLEDDisplay *display, int x, int y, const std::string &line, int fontHeight,
int emoteSpacing = 1, bool fauxBold = true)
{
drawStringWithEmotes(display, x, y, line.c_str(), fontHeight, emoteSpacing, fauxBold);
}
// Check if the display can render a string (detect special chars; emoji)
static bool haveGlyphs(const char *str);
}; // namespace UIRenderer
+2 -2
View File
@@ -10,7 +10,7 @@ const uint8_t ArialMT_Plain_10_RU[] PROGMEM = {
0xE0, // Number of chars: 224
// Jump Table:
0xFF, 0xFF, 0x00, 0x0A, // 32
0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1766,4 +1766,4 @@ const uint8_t ArialMT_Plain_24_RU[] PROGMEM = {
0x3F, // 255
};
#endif // OLED_RU
#endif // OLED_RU
+2 -2
View File
@@ -9,7 +9,7 @@ const uint8_t ArialMT_Plain_10_UA[] PROGMEM = {
0x20, // First char: 32
0xE0, // Number of chars: 224
// Jump Table:
0xFF, 0xFF, 0x00, 0x0A, // 32
0xFF, 0xFF, 0x00, 0x03, // 32
0x00, 0x00, 0x04, 0x03, // 33
0x00, 0x04, 0x05, 0x04, // 34
0x00, 0x09, 0x09, 0x06, // 35
@@ -1924,4 +1924,4 @@ const uint8_t ArialMT_Plain_24_UA[] PROGMEM = {
0xFF, // 1103
};
#endif // OLED_UA
#endif // OLED_UA
+6
View File
@@ -83,6 +83,12 @@ static const unsigned char mail[] PROGMEM = {
0b11111111, 0b00 // Bottom line
};
// Hop icon (9x10)
#define hop_width 9
#define hop_height 10
const uint8_t hop[] PROGMEM = {0x05, 0x00, 0x07, 0x00, 0x05, 0x00, 0x38, 0x00, 0x28, 0x00,
0x38, 0x00, 0xC0, 0x01, 0x40, 0x01, 0xC0, 0x01, 0x40, 0x00};
// 📬 Mail / Message
const uint8_t icon_mail[] PROGMEM = {
0b11111111, // ████████ top border
@@ -42,7 +42,7 @@ int LatchingBacklight::beforeDeepSleep(void *unused)
{
// Contingency only
// - pin wasn't set
if (pin != (uint8_t)-1) {
if (pin != static_cast<uint8_t>(-1)) {
off();
pinMode(pin, INPUT); // High impedance - unnecessary?
} else
@@ -55,7 +55,7 @@ int LatchingBacklight::beforeDeepSleep(void *unused)
// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling
void LatchingBacklight::peek()
{
assert(pin != (uint8_t)-1);
assert(pin != static_cast<uint8_t>(-1));
digitalWrite(pin, logicActive); // On
on = true;
latched = false;
@@ -67,7 +67,7 @@ void LatchingBacklight::peek()
// The effect on the backlight is the same; peek and latch are separated to simplify short vs long press button handling
void LatchingBacklight::latch()
{
assert(pin != (uint8_t)-1);
assert(pin != static_cast<uint8_t>(-1));
// Blink if moving from peek to latch
// Indicates to user that the transition has taken place
@@ -89,7 +89,7 @@ void LatchingBacklight::latch()
// Suitable for ending both peek and latch
void LatchingBacklight::off()
{
assert(pin != (uint8_t)-1);
assert(pin != static_cast<uint8_t>(-1));
digitalWrite(pin, !logicActive); // Off
on = false;
latched = false;
@@ -40,7 +40,7 @@ class LatchingBacklight
CallbackObserver<LatchingBacklight, void *> deepSleepObserver =
CallbackObserver<LatchingBacklight, void *>(this, &LatchingBacklight::beforeDeepSleep);
uint8_t pin = (uint8_t)-1;
uint8_t pin = static_cast<uint8_t>(-1);
bool logicActive = HIGH; // Is light active HIGH or active LOW
bool on = false; // Is light on (either peek or latched)
@@ -37,8 +37,8 @@ class DEPG0213BNS800 : public SSD16XX
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
void finalizeUpdate() override; // Only overriden for a slight optimization
void finalizeUpdate() override; // Only overridden for a slight optimization
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -35,8 +35,8 @@ class DEPG0290BNS800 : public SSD16XX
void configWaveform() override;
void configUpdateSequence() override;
void detachFromUpdate() override;
void finalizeUpdate() override; // Only overriden for a slight optimization
void finalizeUpdate() override; // Only overridden for a slight optimization
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -0,0 +1,178 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./GDEW0102T4.h"
#include <cstring>
using namespace NicheGraphics::Drivers;
// LUTs from GxEPD2_102.cpp (GDEW0102T4 / UC8175).
static const uint8_t LUT_W_FULL[] = {
0x60, 0x5A, 0x5A, 0x00, 0x00, 0x01, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
};
static const uint8_t LUT_B_FULL[] = {
0x90, 0x5A, 0x5A, 0x00, 0x00, 0x01, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
};
static const uint8_t LUT_W_FAST[] = {
0x60, 0x01, 0x01, 0x00, 0x00, 0x01, //
0x80, 0x12, 0x00, 0x00, 0x00, 0x01, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
};
static const uint8_t LUT_B_FAST[] = {
0x90, 0x01, 0x01, 0x00, 0x00, 0x01, //
0x40, 0x14, 0x00, 0x00, 0x00, 0x01, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
};
GDEW0102T4::GDEW0102T4() : UC8175(width, height, supported) {}
void GDEW0102T4::setFastConfig(FastConfig cfg)
{
// Clamp out only clearly invalid PLL settings.
if (cfg.reg30 < 0x05)
cfg.reg30 = 0x05;
fastConfig = cfg;
}
GDEW0102T4::FastConfig GDEW0102T4::getFastConfig() const
{
return fastConfig;
}
void GDEW0102T4::configCommon()
{
// Init path aligned with GxEPD2_GDEW0102T4 (UC8175 family).
sendCommand(0xD2);
sendData(0x3F);
sendCommand(0x00);
sendData(0x6F);
sendCommand(0x01);
sendData(0x03);
sendData(0x00);
sendData(0x2B);
sendData(0x2B);
sendCommand(0x06);
sendData(0x3F);
sendCommand(0x2A);
sendData(0x00);
sendData(0x00);
sendCommand(0x30); // PLL / drive clock
sendData(0x13);
sendCommand(0x50); // Last border/data interval; subtle but can affect artifacts
sendData(0x57);
sendCommand(0x60);
sendData(0x22);
sendCommand(0x61);
sendData(width);
sendData(height);
sendCommand(0x82); // VCOM DC setting
sendData(0x12);
sendCommand(0xE3);
sendData(0x33);
}
void GDEW0102T4::configFull()
{
sendCommand(0x23);
sendData(LUT_W_FULL, sizeof(LUT_W_FULL));
sendCommand(0x24);
sendData(LUT_B_FULL, sizeof(LUT_B_FULL));
powerOn();
}
void GDEW0102T4::configFast()
{
uint8_t lutW[sizeof(LUT_W_FAST)];
uint8_t lutB[sizeof(LUT_B_FAST)];
memcpy(lutW, LUT_W_FAST, sizeof(LUT_W_FAST));
memcpy(lutB, LUT_B_FAST, sizeof(LUT_B_FAST));
// Second stage duration bytes are the main "darkness vs ghosting" control for this panel.
lutW[7] = fastConfig.lutW2;
lutB[7] = fastConfig.lutB2;
sendCommand(0x30);
sendData(fastConfig.reg30);
sendCommand(0x50);
sendData(fastConfig.reg50);
sendCommand(0x82);
sendData(fastConfig.reg82);
sendCommand(0x23);
sendData(lutW, sizeof(lutW));
sendCommand(0x24);
sendData(lutB, sizeof(lutB));
powerOn();
}
void GDEW0102T4::writeOldImage()
{
// On this panel, FULL refresh is most reliable when "old image" is all white.
if (updateType == FULL) {
sendCommand(0x10);
// Use buffered writes of 0xFF to avoid per-byte SPI transactions.
const uint16_t chunkSize = 64;
uint8_t ffBuf[chunkSize];
memset(ffBuf, 0xFF, sizeof(ffBuf));
uint32_t remaining = bufferSize;
while (remaining > 0) {
uint16_t toSend = remaining > chunkSize ? chunkSize : static_cast<uint16_t>(remaining);
sendData(ffBuf, toSend);
remaining -= toSend;
}
return;
}
// FAST refresh uses differential data (previous frame as old image).
if (previousBuffer) {
writeImage(0x10, previousBuffer);
} else {
writeImage(0x10, buffer);
}
}
void GDEW0102T4::finalizeUpdate()
{
// Keep panel out of deep-sleep between updates for better reliability of repeated FAST refresh.
powerOff();
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
@@ -0,0 +1,55 @@
/*
E-Ink display driver
- GDEW0102T4
- Controller: UC8175
- Size: 1.02 inch
- Resolution: 80px x 128px
*/
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./UC8175.h"
namespace NicheGraphics::Drivers
{
class GDEW0102T4 : public UC8175
{
private:
static constexpr uint16_t width = 80;
static constexpr uint16_t height = 128;
static constexpr UpdateTypes supported = (UpdateTypes)(FULL | FAST);
public:
struct FastConfig {
uint8_t reg30;
uint8_t reg50;
uint8_t reg82;
uint8_t lutW2;
uint8_t lutB2;
};
GDEW0102T4();
void setFastConfig(FastConfig cfg);
FastConfig getFastConfig() const;
protected:
void configCommon() override;
void configFull() override;
void configFast() override;
void writeOldImage() override;
void finalizeUpdate() override;
private:
FastConfig fastConfig = {0x13, 0xF2, 0x12, 0x0E, 0x14};
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+1 -1
View File
@@ -19,7 +19,7 @@ void setupNicheGraphics()
SPIClass *hspi = new SPIClass(HSPI);
hspi->begin(PIN_EINK_SCLK, -1, PIN_EINK_MOSI, PIN_EINK_CS);
// Setup Enk driver
// Setup EInk driver
Drivers::EInk *driver = new Drivers::DEPG0290BNS800;
driver->begin(hspi, PIN_EINK_DC, PIN_EINK_CS, PIN_EINK_BUSY);
+203
View File
@@ -0,0 +1,203 @@
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "./UC8175.h"
#include <cstring>
#include "SPILock.h"
using namespace NicheGraphics::Drivers;
UC8175::UC8175(uint16_t width, uint16_t height, UpdateTypes supported) : EInk(width, height, supported)
{
bufferRowSize = ((width - 1) / 8) + 1;
bufferSize = bufferRowSize * height;
}
void UC8175::begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst)
{
this->spi = spi;
this->pin_dc = pin_dc;
this->pin_cs = pin_cs;
this->pin_busy = pin_busy;
this->pin_rst = pin_rst;
pinMode(pin_dc, OUTPUT);
pinMode(pin_cs, OUTPUT);
pinMode(pin_busy, INPUT);
// Reset is active LOW, hold HIGH when idle.
if (pin_rst != (uint8_t)-1) {
pinMode(pin_rst, OUTPUT);
digitalWrite(pin_rst, HIGH);
}
if (!previousBuffer) {
previousBuffer = new uint8_t[bufferSize];
if (previousBuffer)
memset(previousBuffer, 0xFF, bufferSize);
}
}
void UC8175::update(uint8_t *imageData, UpdateTypes type)
{
buffer = imageData;
updateType = (type == UpdateTypes::UNSPECIFIED) ? UpdateTypes::FULL : type;
if (updateType == FAST && hasPreviousBuffer && previousBuffer && memcmp(previousBuffer, buffer, bufferSize) == 0)
return;
reset();
configCommon();
if (updateType == FAST)
configFast();
else
configFull();
writeOldImage();
writeNewImage();
sendCommand(0x12); // Display refresh.
if (previousBuffer) {
memcpy(previousBuffer, buffer, bufferSize);
hasPreviousBuffer = true;
}
detachFromUpdate();
}
void UC8175::wait(uint32_t timeoutMs)
{
if (failed)
return;
uint32_t started = millis();
while (digitalRead(pin_busy) == BUSY_ACTIVE) {
if ((millis() - started) > timeoutMs) {
failed = true;
break;
}
yield();
}
}
void UC8175::reset()
{
if (pin_rst != (uint8_t)-1) {
digitalWrite(pin_rst, LOW);
delay(20);
digitalWrite(pin_rst, HIGH);
delay(20);
} else {
sendCommand(0x12); // Software reset.
delay(10);
}
wait(3000);
}
void UC8175::sendCommand(uint8_t command)
{
if (failed)
return;
spiLock->lock();
spi->beginTransaction(spiSettings);
digitalWrite(pin_dc, LOW);
digitalWrite(pin_cs, LOW);
spi->transfer(command);
digitalWrite(pin_cs, HIGH);
digitalWrite(pin_dc, HIGH);
spi->endTransaction();
spiLock->unlock();
}
void UC8175::sendData(uint8_t data)
{
sendData(&data, 1);
}
void UC8175::sendData(const uint8_t *data, uint32_t size)
{
if (failed)
return;
spiLock->lock();
spi->beginTransaction(spiSettings);
digitalWrite(pin_dc, HIGH);
digitalWrite(pin_cs, LOW);
#if defined(ARCH_ESP32)
spi->transferBytes(data, NULL, size);
#elif defined(ARCH_NRF52)
spi->transfer(data, NULL, size);
#else
for (uint32_t i = 0; i < size; ++i)
spi->transfer(data[i]);
#endif
digitalWrite(pin_cs, HIGH);
digitalWrite(pin_dc, HIGH);
spi->endTransaction();
spiLock->unlock();
}
void UC8175::powerOn()
{
sendCommand(0x04);
wait(2000);
}
void UC8175::powerOff()
{
sendCommand(0x02); // Power off.
wait(1500);
}
void UC8175::writeImage(uint8_t command, const uint8_t *image)
{
sendCommand(command);
sendData(image, bufferSize);
}
void UC8175::writeOldImage()
{
if (updateType == FAST && previousBuffer)
writeImage(0x10, previousBuffer);
else
writeImage(0x10, buffer);
}
void UC8175::writeNewImage()
{
writeImage(0x13, buffer);
}
void UC8175::detachFromUpdate()
{
switch (updateType) {
case FAST:
return beginPolling(50, 400);
case FULL:
default:
return beginPolling(100, 2000);
}
}
bool UC8175::isUpdateDone()
{
return digitalRead(pin_busy) != BUSY_ACTIVE;
}
void UC8175::finalizeUpdate()
{
powerOff();
if (pin_rst != (uint8_t)-1) {
sendCommand(0x07); // Deep sleep.
sendData(0xA5);
}
}
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+62
View File
@@ -0,0 +1,62 @@
// E-Ink base class for displays based on UC8175 / UC8176 style controller ICs.
#pragma once
#ifdef MESHTASTIC_INCLUDE_NICHE_GRAPHICS
#include "configuration.h"
#include "./EInk.h"
namespace NicheGraphics::Drivers
{
class UC8175 : public EInk
{
public:
UC8175(uint16_t width, uint16_t height, UpdateTypes supported);
void begin(SPIClass *spi, uint8_t pin_dc, uint8_t pin_cs, uint8_t pin_busy, uint8_t pin_rst = -1) override;
void update(uint8_t *imageData, UpdateTypes type) override;
protected:
virtual void wait(uint32_t timeoutMs = 1000);
virtual void reset();
virtual void sendCommand(uint8_t command);
virtual void sendData(uint8_t data);
virtual void sendData(const uint8_t *data, uint32_t size);
virtual void configCommon() = 0; // Always run
virtual void configFull() = 0; // Run when updateType == FULL
virtual void configFast() = 0; // Run when updateType == FAST
virtual void powerOn();
virtual void powerOff();
virtual void writeOldImage();
virtual void writeNewImage();
virtual void writeImage(uint8_t command, const uint8_t *image);
virtual void detachFromUpdate();
virtual bool isUpdateDone() override;
virtual void finalizeUpdate() override;
protected:
static constexpr uint8_t BUSY_ACTIVE = LOW;
uint16_t bufferRowSize = 0;
uint32_t bufferSize = 0;
uint8_t *buffer = nullptr;
uint8_t *previousBuffer = nullptr;
bool hasPreviousBuffer = false;
UpdateTypes updateType = UpdateTypes::UNSPECIFIED;
uint8_t pin_dc = (uint8_t)-1;
uint8_t pin_cs = (uint8_t)-1;
uint8_t pin_busy = (uint8_t)-1;
uint8_t pin_rst = (uint8_t)-1;
SPIClass *spi = nullptr;
SPISettings spiSettings = SPISettings(8000000, MSBFIRST, SPI_MODE0);
};
} // namespace NicheGraphics::Drivers
#endif // MESHTASTIC_INCLUDE_NICHE_GRAPHICS
+52 -18
View File
@@ -1,3 +1,5 @@
#include "graphics/niche/InkHUD/Tile.h"
#include <cstdint>
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./Applet.h"
@@ -32,7 +34,7 @@ void InkHUD::Applet::drawPixel(int16_t x, int16_t y, uint16_t color)
{
// Only render pixels if they fall within user's cropped region
if (x >= cropLeft && x < (cropLeft + cropWidth) && y >= cropTop && y < (cropTop + cropHeight))
assignedTile->handleAppletPixel(x, y, (Color)color);
assignedTile->handleAppletPixel(x, y, static_cast<Color>(color));
}
// Link our applet to a tile
@@ -55,7 +57,7 @@ InkHUD::Tile *InkHUD::Applet::getTile()
}
// Draw the applet
void InkHUD::Applet::render()
void InkHUD::Applet::render(bool full)
{
assert(assignedTile); // Ensure that we have a tile
assert(assignedTile->getAssignedApplet() == this); // Ensure that we have a reciprocal link with the tile
@@ -65,10 +67,11 @@ void InkHUD::Applet::render()
wantRender = false; // Flag set by requestUpdate
wantAutoshow = false; // Flag set by requestAutoShow. May or may not have been honored.
wantUpdateType = Drivers::EInk::UpdateTypes::UNSPECIFIED; // Update type we wanted. May on may not have been granted.
wantFullRender = true; // Default to a full render
updateDimensions();
resetDrawingSpace();
onRender(); // Derived applet's drawing takes place here
onRender(full); // Draw the applet
// Handle "Tile Highlighting"
// Some devices may use an auxiliary button to switch between tiles
@@ -115,6 +118,11 @@ Drivers::EInk::UpdateTypes InkHUD::Applet::wantsUpdateType()
return wantUpdateType;
}
bool InkHUD::Applet::wantsFullRender()
{
return wantFullRender;
}
// Get size of the applet's drawing space from its tile
// Performed immediately before derived applet's drawing code runs
void InkHUD::Applet::updateDimensions()
@@ -136,16 +144,32 @@ void InkHUD::Applet::resetDrawingSpace()
setFont(fontSmall);
}
// Sets one or more inputs to enabled/disabled for this applet and if they should be sent to it
void InkHUD::Applet::setInputsSubscribed(uint8_t input, bool captured)
{
if (captured)
subscribedInputs |= input;
else
subscribedInputs &= ~input;
}
// Checks if a specific input is enabled for this applet and should be sent to it
bool InkHUD::Applet::isInputSubscribed(InputMask input)
{
return (subscribedInputs & input) == input;
}
// Tell InkHUD::Renderer that we want to render now
// Applets should internally listen for events they are interested in, via MeshModule, CallbackObserver etc
// When an applet decides it has heard something important, and wants to redraw, it calls this method
// Once the renderer has given other applets a chance to process whatever event we just detected,
// it will run Applet::render(), which may draw our applet to screen, if it is shown (foreground)
// We should requestUpdate even if our applet is currently background, because this might be changed by autoshow
void InkHUD::Applet::requestUpdate(Drivers::EInk::UpdateTypes type)
void InkHUD::Applet::requestUpdate(Drivers::EInk::UpdateTypes type, bool full)
{
wantRender = true;
wantUpdateType = type;
wantFullRender = full;
inkhud->requestUpdate();
}
@@ -303,7 +327,7 @@ void InkHUD::Applet::printAt(int16_t x, int16_t y, const char *text, HorizontalA
}
// Print text, specifying the position of any edge / corner of the textbox
void InkHUD::Applet::printAt(int16_t x, int16_t y, std::string text, HorizontalAlignment ha, VerticalAlignment va)
void InkHUD::Applet::printAt(int16_t x, int16_t y, const std::string &text, HorizontalAlignment ha, VerticalAlignment va)
{
printAt(x, y, text.c_str(), ha, va);
}
@@ -325,7 +349,7 @@ InkHUD::AppletFont InkHUD::Applet::getFont()
// Parse any text which might have "special characters"
// Re-encodes UTF-8 characters to match our 8-bit encoded fonts
std::string InkHUD::Applet::parse(std::string text)
std::string InkHUD::Applet::parse(const std::string &text)
{
return getFont().decodeUTF8(text);
}
@@ -352,10 +376,10 @@ std::string InkHUD::Applet::parseShortName(meshtastic_NodeInfoLite *node)
}
// Determine if all characters of a string are printable using the current font
bool InkHUD::Applet::isPrintable(std::string text)
bool InkHUD::Applet::isPrintable(const std::string &text)
{
// Scan for SUB (0x1A), which is the value assigned by AppletFont::applyEncoding if a unicode character is not handled
for (char &c : text) {
for (const char &c : text) {
if (c == '\x1A')
return false;
}
@@ -378,7 +402,7 @@ uint16_t InkHUD::Applet::getTextWidth(const char *text)
// Gets rendered width of a string
// Wrapper for getTextBounds
uint16_t InkHUD::Applet::getTextWidth(std::string text)
uint16_t InkHUD::Applet::getTextWidth(const std::string &text)
{
return getTextWidth(text.c_str());
}
@@ -426,7 +450,7 @@ std::string InkHUD::Applet::hexifyNodeNum(NodeNum num)
// Print text, with word wrapping
// Avoids splitting words in half, instead moving the entire word to a new line wherever possible
void InkHUD::Applet::printWrapped(int16_t left, int16_t top, uint16_t width, std::string text)
void InkHUD::Applet::printWrapped(int16_t left, int16_t top, uint16_t width, const std::string &text)
{
// Place the AdafruitGFX cursor to suit our "top" coord
setCursor(left, top + getFont().heightAboveCursor());
@@ -483,15 +507,15 @@ void InkHUD::Applet::printWrapped(int16_t left, int16_t top, uint16_t width, std
// Todo: rewrite making use of AdafruitGFX native text wrapping
char cstr[] = {0, 0};
int16_t l, t;
uint16_t w, h;
int16_t bx, by;
uint16_t bw, bh;
for (uint16_t c = 0; c < word.length(); c++) {
// Shove next char into a c string
cstr[0] = word[c];
getTextBounds(cstr, getCursorX(), getCursorY(), &l, &t, &w, &h);
getTextBounds(cstr, getCursorX(), getCursorY(), &bx, &by, &bw, &bh);
// Manual newline, if next character will spill beyond screen edge
if ((l + w) > left + width)
if ((bx + bw) > left + width)
setCursor(left, getCursorY() + getFont().lineHeight());
// Print next character
@@ -510,7 +534,7 @@ void InkHUD::Applet::printWrapped(int16_t left, int16_t top, uint16_t width, std
// Simulate running printWrapped, to determine how tall the block of text will be.
// This is a wasteful way of handling things. Maybe some way to optimize in future?
uint32_t InkHUD::Applet::getWrappedTextHeight(int16_t left, uint16_t width, std::string text)
uint32_t InkHUD::Applet::getWrappedTextHeight(int16_t left, uint16_t width, const std::string &text)
{
// Cache the current crop region
int16_t cL = cropLeft;
@@ -640,7 +664,7 @@ uint16_t InkHUD::Applet::getActiveNodeCount()
// For each node in db
for (uint16_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
// Check if heard recently, and not our own node
if (sinceLastSeen(node) < settings->recentlyActiveSeconds && node->num != nodeDB->getNodeNum())
@@ -693,7 +717,7 @@ std::string InkHUD::Applet::localizeDistance(uint32_t meters)
}
// Print text with a "faux bold" effect, by drawing it multiple times, offsetting slightly
void InkHUD::Applet::printThick(int16_t xCenter, int16_t yCenter, std::string text, uint8_t thicknessX, uint8_t thicknessY)
void InkHUD::Applet::printThick(int16_t xCenter, int16_t yCenter, const std::string &text, uint8_t thicknessX, uint8_t thicknessY)
{
// How many times to draw along x axis
int16_t xStart;
@@ -761,7 +785,7 @@ bool InkHUD::Applet::approveNotification(NicheGraphics::InkHUD::Notification &n)
│ │
└───────────────────────────────┘
*/
void InkHUD::Applet::drawHeader(std::string text)
void InkHUD::Applet::drawHeader(const std::string &text)
{
// Y position for divider
// - between header text and messages
@@ -778,6 +802,16 @@ void InkHUD::Applet::drawHeader(std::string text)
drawPixel(x, 0, BLACK);
drawPixel(x, headerDivY, BLACK); // Dotted 50%
}
// Dither near battery
if (settings->optionalFeatures.batteryIcon) {
constexpr uint16_t ditherSizePx = 4;
Tile *batteryTile = ((Applet *)inkhud->getSystemApplet("BatteryIcon"))->getTile();
const uint16_t batteryTileLeft = batteryTile->getLeft();
const uint16_t batteryTileTop = batteryTile->getTop();
const uint16_t batteryTileHeight = batteryTile->getHeight();
hatchRegion(batteryTileLeft - ditherSizePx, batteryTileTop, ditherSizePx, batteryTileHeight, 2, WHITE);
}
}
// Get the height of the standard applet header
+44 -14
View File
@@ -3,7 +3,7 @@
/*
Base class for InkHUD applets
Must be overriden
Must be overridden
An applet is one "program" which may show info on the display.
@@ -15,6 +15,7 @@
#include <GFX.h> // GFXRoot drawing lib
#include "mesh/MeshModule.h"
#include "mesh/MeshTypes.h"
#include "./AppletFont.h"
@@ -64,10 +65,11 @@ class Applet : public GFX
// Rendering
void render(); // Draw the applet
void render(bool full); // Draw the applet
bool wantsToRender(); // Check whether applet wants to render
bool wantsToAutoshow(); // Check whether applet wants to become foreground
Drivers::EInk::UpdateTypes wantsUpdateType(); // Check which display update type the applet would prefer
bool wantsFullRender(); // Check whether applet wants to render over its previous render
void updateDimensions(); // Get current size from tile
void resetDrawingSpace(); // Makes sure every render starts with same parameters
@@ -82,12 +84,15 @@ class Applet : public GFX
// Event handlers
virtual void onRender() = 0; // All drawing happens here
virtual void onRender(bool full) = 0; // For drawing the applet
virtual void onActivate() {}
virtual void onDeactivate() {}
virtual void onForeground() {}
virtual void onBackground() {}
virtual void onShutdown() {}
// Input Events
virtual void onButtonShortPress() {}
virtual void onButtonLongPress() {}
virtual void onExitShort() {}
@@ -96,6 +101,21 @@ class Applet : public GFX
virtual void onNavDown() {}
virtual void onNavLeft() {}
virtual void onNavRight() {}
virtual void onFreeText(char c) {}
virtual void onFreeTextDone() {}
virtual void onFreeTextCancel() {}
// List of inputs which can be subscribed to
enum InputMask { // | No Joystick | With Joystick |
BUTTON_SHORT = 1, // | Button Click | Joystick Center Click |
BUTTON_LONG = 2, // | Button Hold | Joystick Center Hold |
EXIT_SHORT = 4, // | no-op | Back Button Click |
EXIT_LONG = 8, // | no-op | Back Button Hold |
NAV_UP = 16, // | no-op | Joystick Up |
NAV_DOWN = 32, // | no-op | Joystick Down |
NAV_LEFT = 64, // | no-op | Joystick Left |
NAV_RIGHT = 128 // | no-op | Joystick Right |
};
bool isInputSubscribed(InputMask input); // Check if input should be handled by applet, this should not be overloaded.
virtual bool approveNotification(Notification &n); // Allow an applet to veto a notification
@@ -108,28 +128,37 @@ class Applet : public GFX
protected:
void drawPixel(int16_t x, int16_t y, uint16_t color) override; // Place a single pixel. All drawing output passes through here
void requestUpdate(EInk::UpdateTypes type = EInk::UpdateTypes::UNSPECIFIED); // Ask WindowManager to schedule a display update
void requestAutoshow(); // Ask for applet to be moved to foreground
void requestUpdate(EInk::UpdateTypes type = EInk::UpdateTypes::UNSPECIFIED,
bool full = true); // Ask WindowManager to schedule a display update
void requestAutoshow(); // Ask for applet to be moved to foreground
uint16_t X(float f); // Map applet width, mapped from 0 to 1.0
uint16_t Y(float f); // Map applet height, mapped from 0 to 1.0
void setCrop(int16_t left, int16_t top, uint16_t width, uint16_t height); // Ignore pixels drawn outside a certain region
void resetCrop(); // Removes setCrop()
// User Input Handling
uint8_t subscribedInputs = 0b00000000; // Maybe uint16_t for futureproofing? other devices may need more inputs
void setInputsSubscribed(uint8_t input,
bool captured); // Set if an input should be handled by applet or not, this should not be
// overloaded. Can take multiple inputs at once if you OR/`|` them together
// Text
void setFont(AppletFont f);
AppletFont getFont();
uint16_t getTextWidth(std::string text);
uint16_t getTextWidth(const std::string &text);
uint16_t getTextWidth(const char *text);
uint32_t getWrappedTextHeight(int16_t left, uint16_t width, std::string text); // Result of printWrapped
uint32_t getWrappedTextHeight(int16_t left, uint16_t width, const std::string &text); // Result of printWrapped
void printAt(int16_t x, int16_t y, const char *text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP);
void printAt(int16_t x, int16_t y, std::string text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP);
void printThick(int16_t xCenter, int16_t yCenter, std::string text, uint8_t thicknessX, uint8_t thicknessY); // Faux bold
void printWrapped(int16_t left, int16_t top, uint16_t width, std::string text); // Per-word line wrapping
void printAt(int16_t x, int16_t y, const std::string &text, HorizontalAlignment ha = LEFT, VerticalAlignment va = TOP);
void printThick(int16_t xCenter, int16_t yCenter, const std::string &text, uint8_t thicknessX,
uint8_t thicknessY); // Faux bold
void printWrapped(int16_t left, int16_t top, uint16_t width, const std::string &text); // Per-word line wrapping
void hatchRegion(int16_t x, int16_t y, uint16_t w, uint16_t h, uint8_t spacing, Color color); // Fill with sparse lines
void drawHeader(std::string text); // Draw the standard applet header
void drawHeader(const std::string &text); // Draw the standard applet header
// Meshtastic Logo
@@ -145,9 +174,9 @@ class Applet : public GFX
std::string getTimeString(); // Current time, human readable
uint16_t getActiveNodeCount(); // Duration determined by user, in onscreen menu
std::string localizeDistance(uint32_t meters); // Human readable distance, imperial or metric
std::string parse(std::string text); // Handle text which might contain special chars
std::string parse(const std::string &text); // Handle text which might contain special chars
std::string parseShortName(meshtastic_NodeInfoLite *node); // Get the shortname, or a substitute if has unprintable chars
bool isPrintable(std::string); // Check for characters which the font can't print
bool isPrintable(const std::string &text); // Check for characters which the font can't print
// Convenient references
@@ -164,6 +193,7 @@ class Applet : public GFX
bool wantAutoshow = false; // Does the applet have new data it would like to display in foreground?
NicheGraphics::Drivers::EInk::UpdateTypes wantUpdateType =
NicheGraphics::Drivers::EInk::UpdateTypes::UNSPECIFIED; // Which update method we'd prefer when redrawing the display
bool wantFullRender = true; // Render with a fresh canvas
using GFX::setFont; // Make sure derived classes use AppletFont instead of AdafruitGFX fonts directly
using GFX::setRotation; // Block setRotation calls. Rotation is handled globally by WindowManager.
@@ -179,4 +209,4 @@ class Applet : public GFX
}; // namespace NicheGraphics::InkHUD
#endif
#endif
+7 -7
View File
@@ -39,11 +39,11 @@ InkHUD::AppletFont::AppletFont(const GFXfont &adafruitGFXFont, Encoding encoding
// Caution: signed and unsigned types
int8_t glyphAscender = 0 - gfxFont->glyph[i].yOffset;
if (glyphAscender > 0)
this->ascenderHeight = max(this->ascenderHeight, (uint8_t)glyphAscender);
this->ascenderHeight = max(this->ascenderHeight, static_cast<uint8_t>(glyphAscender));
int8_t glyphDescender = gfxFont->glyph[i].height + gfxFont->glyph[i].yOffset;
if (glyphDescender > 0)
this->descenderHeight = max(this->descenderHeight, (uint8_t)glyphDescender);
this->descenderHeight = max(this->descenderHeight, static_cast<uint8_t>(glyphDescender));
}
// Apply any manual padding to grow or shrink the line size
@@ -52,7 +52,7 @@ InkHUD::AppletFont::AppletFont(const GFXfont &adafruitGFXFont, Encoding encoding
descenderHeight += paddingBottom;
// Find how far the cursor advances when we "print" a space character
spaceCharWidth = gfxFont->glyph[(uint8_t)' ' - gfxFont->first].xAdvance;
spaceCharWidth = gfxFont->glyph[static_cast<uint8_t>(' ') - gfxFont->first].xAdvance;
}
/*
@@ -98,7 +98,7 @@ uint8_t InkHUD::AppletFont::widthBetweenWords()
// Convert a unicode char from set of UTF-8 bytes to UTF-32
// Used by AppletFont::applyEncoding, which remaps unicode chars for extended ASCII fonts, based on their UTF-32 value
uint32_t InkHUD::AppletFont::toUtf32(std::string utf8)
uint32_t InkHUD::AppletFont::toUtf32(const std::string &utf8)
{
uint32_t utf32 = 0;
@@ -132,7 +132,7 @@ uint32_t InkHUD::AppletFont::toUtf32(std::string utf8)
// Process a string, collating UTF-8 bytes, and sending them off for re-encoding to extended ASCII
// Not all InkHUD text is passed through here, only text which could potentially contain non-ASCII chars
std::string InkHUD::AppletFont::decodeUTF8(std::string encoded)
std::string InkHUD::AppletFont::decodeUTF8(const std::string &encoded)
{
// Final processed output
std::string decoded;
@@ -141,7 +141,7 @@ std::string InkHUD::AppletFont::decodeUTF8(std::string encoded)
std::string utf8Char;
uint8_t utf8CharSize = 0;
for (char &c : encoded) {
for (const char &c : encoded) {
// If first byte
if (utf8Char.empty()) {
@@ -178,7 +178,7 @@ std::string InkHUD::AppletFont::decodeUTF8(std::string encoded)
// Re-encode a single UTF-8 character to extended ASCII
// Target encoding depends on the font
char InkHUD::AppletFont::applyEncoding(std::string utf8)
char InkHUD::AppletFont::applyEncoding(const std::string &utf8)
{
// ##################################################### Syntactic Sugar #####################################################
#define REMAP(in, out) \
+6 -5
View File
@@ -30,20 +30,21 @@ class AppletFont
};
AppletFont();
AppletFont(const GFXfont &adafruitGFXFont, Encoding encoding = ASCII, int8_t paddingTop = 0, int8_t paddingBottom = 0);
explicit AppletFont(const GFXfont &adafruitGFXFont, Encoding encoding = ASCII, int8_t paddingTop = 0,
int8_t paddingBottom = 0);
uint8_t lineHeight();
uint8_t heightAboveCursor();
uint8_t heightBelowCursor();
uint8_t widthBetweenWords(); // Width of the space character
std::string decodeUTF8(std::string encoded);
std::string decodeUTF8(const std::string &encoded);
const GFXfont *gfxFont = NULL; // Default value: in-built AdafruitGFX font
const GFXfont *gfxFont = nullptr; // Default value: in-built AdafruitGFX font
private:
uint32_t toUtf32(std::string utf8);
char applyEncoding(std::string utf8);
uint32_t toUtf32(const std::string &utf8);
char applyEncoding(const std::string &utf8);
uint8_t height = 8; // Default value: in-built AdafruitGFX font
uint8_t ascenderHeight = 0; // Default value: in-built AdafruitGFX font
@@ -4,7 +4,7 @@
using namespace NicheGraphics;
void InkHUD::MapApplet::onRender()
void InkHUD::MapApplet::onRender(bool full)
{
// Abort if no markers to render
if (!enoughMarkers()) {
@@ -525,7 +525,7 @@ void InkHUD::MapApplet::calculateAllMarkers()
}
// Determine the conversion factor between metres, and pixels on screen
// May be overriden by derived applet, if custom scale required (fixed map size?)
// May be overridden by derived applet, if custom scale required (fixed map size?)
void InkHUD::MapApplet::calculateMapScale()
{
// Aspect ratio of map and screen
@@ -555,4 +555,4 @@ void InkHUD::MapApplet::drawCross(int16_t x, int16_t y, uint8_t size)
drawLine(x0, y1, x1, y0, BLACK);
}
#endif
#endif
@@ -27,7 +27,7 @@ namespace NicheGraphics::InkHUD
class MapApplet : public Applet
{
public:
void onRender() override;
void onRender(bool full) override;
protected:
virtual bool shouldDrawNode(meshtastic_NodeInfoLite *node) { return true; } // Allow derived applets to filter the nodes
@@ -81,29 +81,29 @@ ProcessMessage InkHUD::NodeListApplet::handleReceived(const meshtastic_MeshPacke
uint8_t InkHUD::NodeListApplet::maxCards()
{
// Cache result. Shouldn't change during execution
static uint8_t cards = 0;
static uint8_t maxCardCount = 0;
if (!cards) {
if (!maxCardCount) {
const uint16_t height = Tile::maxDisplayDimension();
// Use a loop instead of arithmetic, because it's easier for my brain to follow
// Add cards one by one, until the latest card extends below screen
uint16_t y = cardH; // First card: no margin above
cards = 1;
maxCardCount = 1;
while (y < height) {
y += cardMarginH;
y += cardH;
cards++;
maxCardCount++;
}
}
return cards;
return maxCardCount;
}
// Draw, using info which derived applet placed into NodeListApplet::cards for us
void InkHUD::NodeListApplet::onRender()
void InkHUD::NodeListApplet::onRender(bool full)
{
// ================================
@@ -120,9 +120,26 @@ void InkHUD::NodeListApplet::onRender()
// Draw the main node list
// ========================
// Imaginary vertical line dividing left-side and right-side info
// Long-name will crop here
const uint16_t dividerX = (width() - 1) - getTextWidth("X Hops");
// Leave a small gutter between long-name text and right-side card content
constexpr uint8_t rightContentGap = 2;
// Truncate with trailing "...", sized using the current font.
auto ellipsizeToWidth = [this](std::string text, uint16_t maxWidth) {
constexpr const char *ellipsis = "...";
const uint16_t ellipsisW = getTextWidth(ellipsis);
uint16_t textW = getTextWidth(text);
if (maxWidth == 0)
return std::string();
if (textW <= maxWidth)
return text;
if (ellipsisW > maxWidth)
return std::string();
while (!text.empty() && (textW + ellipsisW > maxWidth)) {
text.pop_back();
textW = getTextWidth(text);
}
return text + ellipsis;
};
// Y value (top) of the current card. Increases as we draw.
uint16_t cardTopY = headerDivY + padDivH;
@@ -137,12 +154,12 @@ void InkHUD::NodeListApplet::onRender()
// Gather info
// ========================================
NodeNum &nodeNum = card->nodeNum;
const NodeNum &nodeNum = card->nodeNum;
SignalStrength &signal = card->signal;
std::string longName; // handled below
std::string shortName; // handled below
std::string distance; // handled below;
uint8_t &hopsAway = card->hopsAway;
std::string distance; // handled below
const uint8_t &hopsAway = card->hopsAway;
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(nodeNum);
@@ -185,41 +202,49 @@ void InkHUD::NodeListApplet::onRender()
setFont(fontMedium);
printAt(0, lineAY, shortName, LEFT, MIDDLE);
// Print the distance
// Right-side labels and long name are rendered in small font.
setFont(fontSmall);
printAt(width() - 1, lineBY, distance, RIGHT, MIDDLE);
uint16_t rightContentW = 0;
// If we have a direct connection to the node, draw the signal indicator
// Bottom row right: distance.
if (!distance.empty()) {
rightContentW = std::max(rightContentW, getTextWidth(distance));
printAt(width() - 1, lineBY, distance, RIGHT, MIDDLE);
}
// Top row right: direct-link signal only.
if (hopsAway == 0 && signal != SIGNAL_UNKNOWN) {
uint16_t signalW = getTextWidth("Xkm"); // Indicator should be similar width to distance label
uint16_t signalW = getTextWidth("Xkm"); // Indicator width tuned to a short right-side label
uint16_t signalH = fontMedium.lineHeight() * 0.75;
int16_t signalY = lineAY + (fontMedium.lineHeight() / 2) - (fontMedium.lineHeight() * 0.75);
int16_t signalX = width() - signalW;
rightContentW = std::max(rightContentW, signalW);
drawSignalIndicator(signalX, signalY, signalW, signalH, signal);
}
// Otherwise, print "hops away" info, if available
else if (hopsAway != CardInfo::HOPS_UNKNOWN && node) {
std::string hopString = to_string(node->hops_away);
hopString += " Hop";
if (node->hops_away != 1)
hopString += "s"; // Append s for "Hops", rather than "Hop"
} else if (hopsAway != CardInfo::HOPS_UNKNOWN) {
std::string hopString = to_string(hopsAway) + (hopsAway == 1 ? " Hop" : " Hops");
rightContentW = std::max(rightContentW, getTextWidth(hopString));
printAt(width() - 1, lineAY, hopString, RIGHT, MIDDLE);
}
// Print the long name, cropping to prevent overflow onto the right-side info
setCrop(0, 0, dividerX - 1, height());
printAt(0, lineBY, longName, LEFT, MIDDLE);
// Give long names as much room as possible while still avoiding right side signal and hop space
const uint16_t longNameMaxW =
(rightContentW + rightContentGap < width()) ? (width() - rightContentW - rightContentGap) : 0;
const std::string longNameShown = ellipsizeToWidth(longName, longNameMaxW);
// GFX effect: "hatch" the right edge of longName area
// If a longName has been cropped, it will appear to fade out,
// creating a soft barrier with the right-side info
const int16_t hatchLeft = dividerX - 1 - (fontSmall.lineHeight());
const int16_t hatchWidth = fontSmall.lineHeight();
hatchRegion(hatchLeft, cardTopY, hatchWidth, cardH, 2, WHITE);
// Safety crop
setCrop(0, cardTopY, longNameMaxW, cardH);
printAt(0, lineBY, longNameShown, LEFT, MIDDLE);
resetCrop();
// Draw separator between cards
const int16_t separatorY = cardTopY + cardH - 1;
if (separatorY < height() - 1 && (card + 1) != cards.end()) {
for (int16_t xSep = 0; xSep < width(); xSep += 2)
drawPixel(xSep, separatorY, BLACK);
}
// Prepare to draw the next card
resetCrop();
cardTopY += cardH;
// Once we've run out of screen, stop drawing cards
@@ -288,4 +313,4 @@ void InkHUD::NodeListApplet::drawSignalIndicator(int16_t x, int16_t y, uint16_t
}
}
#endif
#endif
@@ -46,7 +46,7 @@ class NodeListApplet : public Applet, public MeshModule
public:
NodeListApplet(const char *name);
void onRender() override;
void onRender(bool full) override;
bool wantPacket(const meshtastic_MeshPacket *p) override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
@@ -65,10 +65,10 @@ class NodeListApplet : public Applet, public MeshModule
// Card Dimensions
// - for rendering and for maxCards calc
uint8_t cardMarginH = fontSmall.lineHeight() / 2; // Gap between cards
uint8_t cardMarginH = 1; // Gap between cards (minimal to fit more rows)
uint16_t cardH = fontMedium.lineHeight() + fontSmall.lineHeight() + cardMarginH; // Height of card
};
} // namespace NicheGraphics::InkHUD
#endif
#endif
@@ -6,7 +6,7 @@ using namespace NicheGraphics;
// All drawing happens here
// Our basic example doesn't do anything useful. It just passively prints some text.
void InkHUD::BasicExampleApplet::onRender()
void InkHUD::BasicExampleApplet::onRender(bool full)
{
printAt(0, 0, "Hello, World!");
@@ -28,7 +28,7 @@ class BasicExampleApplet : public Applet
// You must have an onRender() method
// All drawing happens here
void onRender() override;
void onRender(bool full) override;
};
} // namespace NicheGraphics::InkHUD
@@ -35,7 +35,7 @@ ProcessMessage InkHUD::NewMsgExampleApplet::handleReceived(const meshtastic_Mesh
// We can trigger a render by calling requestUpdate()
// Render might be called by some external source
// We should always be ready to draw
void InkHUD::NewMsgExampleApplet::onRender()
void InkHUD::NewMsgExampleApplet::onRender(bool full)
{
printAt(0, 0, "Example: NewMsg", LEFT, TOP); // Print top-left corner of text at (0,0)
@@ -34,7 +34,7 @@ class NewMsgExampleApplet : public Applet, public SinglePortModule
NewMsgExampleApplet() : SinglePortModule("NewMsgExampleApplet", meshtastic_PortNum_TEXT_MESSAGE_APP) {}
// All drawing happens here
void onRender() override;
void onRender(bool full) override;
// Your applet might also want to use some of these
// Useful for setting up or tidying up
@@ -0,0 +1,79 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./UserAppletInputExample.h"
using namespace NicheGraphics;
void InkHUD::UserAppletInputExampleApplet::onActivate()
{
setGrabbed(false);
}
void InkHUD::UserAppletInputExampleApplet::onRender(bool full)
{
drawHeader("Input Example");
uint16_t headerHeight = getHeaderHeight();
std::string buttonName;
if (settings->joystick.enabled)
buttonName = "joystick center button";
else
buttonName = "user button";
std::string additional = " | Control is grabbed, long press " + buttonName + " to release controls";
if (!isGrabbed)
additional = " | Control is released, long press " + buttonName + " to grab controls";
printWrapped(0, headerHeight, width(), "Last button: " + lastInput + additional);
}
void InkHUD::UserAppletInputExampleApplet::setGrabbed(bool grabbed)
{
isGrabbed = grabbed;
setInputsSubscribed(BUTTON_SHORT | EXIT_SHORT | EXIT_LONG | NAV_UP | NAV_DOWN | NAV_LEFT | NAV_RIGHT,
grabbed); // Enables/disables grabbing all inputs
setInputsSubscribed(BUTTON_LONG, true); // Always grab this input
}
void InkHUD::UserAppletInputExampleApplet::onButtonShortPress()
{
lastInput = "BUTTON_SHORT";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onButtonLongPress()
{
lastInput = "BUTTON_LONG";
setGrabbed(!isGrabbed);
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onExitShort()
{
lastInput = "EXIT_SHORT";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onExitLong()
{
lastInput = "EXIT_LONG";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onNavUp()
{
lastInput = "NAV_UP";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onNavDown()
{
lastInput = "NAV_DOWN";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onNavLeft()
{
lastInput = "NAV_LEFT";
requestUpdate();
}
void InkHUD::UserAppletInputExampleApplet::onNavRight()
{
lastInput = "NAV_RIGHT";
requestUpdate();
}
#endif
@@ -0,0 +1,36 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#pragma once
#include "configuration.h"
#include "graphics/niche/InkHUD/Applet.h"
namespace NicheGraphics::InkHUD
{
class UserAppletInputExampleApplet : public Applet
{
public:
void onActivate() override;
void onRender(bool full) override;
void onButtonShortPress() override;
void onButtonLongPress() override;
void onExitShort() override;
void onExitLong() override;
void onNavUp() override;
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
private:
std::string lastInput = "None";
bool isGrabbed = false;
void setGrabbed(bool grabbed);
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -10,7 +10,7 @@ InkHUD::AlignStickApplet::AlignStickApplet()
bringToForeground();
}
void InkHUD::AlignStickApplet::onRender()
void InkHUD::AlignStickApplet::onRender(bool full)
{
setFont(fontMedium);
printAt(0, 0, "Align Joystick:");
@@ -152,19 +152,17 @@ void InkHUD::AlignStickApplet::onBackground()
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
// Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
inkhud->forceUpdate(EInk::UpdateTypes::FULL, true);
}
void InkHUD::AlignStickApplet::onButtonLongPress()
{
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::AlignStickApplet::onExitLong()
{
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::AlignStickApplet::onNavUp()
@@ -172,7 +170,6 @@ void InkHUD::AlignStickApplet::onNavUp()
settings->joystick.aligned = true;
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::AlignStickApplet::onNavDown()
@@ -181,7 +178,6 @@ void InkHUD::AlignStickApplet::onNavDown()
settings->joystick.aligned = true;
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::AlignStickApplet::onNavLeft()
@@ -190,7 +186,6 @@ void InkHUD::AlignStickApplet::onNavLeft()
settings->joystick.aligned = true;
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::AlignStickApplet::onNavRight()
@@ -199,7 +194,6 @@ void InkHUD::AlignStickApplet::onNavRight()
settings->joystick.aligned = true;
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
#endif
@@ -23,7 +23,7 @@ class AlignStickApplet : public SystemApplet
public:
AlignStickApplet();
void onRender() override;
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
void onButtonLongPress() override;
@@ -6,6 +6,8 @@ using namespace NicheGraphics;
InkHUD::BatteryIconApplet::BatteryIconApplet()
{
alwaysRender = true; // render every time the screen is updated
// Show at boot, if user has previously enabled the feature
if (settings->optionalFeatures.batteryIcon)
bringToForeground();
@@ -27,10 +29,10 @@ int InkHUD::BatteryIconApplet::onPowerStatusUpdate(const meshtastic::Status *sta
// If we get a different type of status, something has gone weird elsewhere
assert(status->getStatusType() == STATUS_TYPE_POWER);
meshtastic::PowerStatus *powerStatus = (meshtastic::PowerStatus *)status;
const meshtastic::PowerStatus *pwrStatus = (const meshtastic::PowerStatus *)status;
// Get the new state of charge %, and round to the nearest 10%
uint8_t newSocRounded = ((powerStatus->getBatteryChargePercent() + 5) / 10) * 10;
uint8_t newSocRounded = ((pwrStatus->getBatteryChargePercent() + 5) / 10) * 10;
// If rounded value has changed, trigger a display update
// It's okay to requestUpdate before we store the new value, as the update won't run until next loop()
@@ -44,39 +46,29 @@ int InkHUD::BatteryIconApplet::onPowerStatusUpdate(const meshtastic::Status *sta
return 0; // Tell Observable to continue informing other observers
}
void InkHUD::BatteryIconApplet::onRender()
void InkHUD::BatteryIconApplet::onRender(bool full)
{
// Fill entire tile
// - size of icon controlled by size of tile
int16_t l = 0;
int16_t t = 0;
uint16_t w = width();
int16_t h = height();
// Clear the region beneath the tile
// Clear the region beneath the tile, including the border
// Most applets are drawing onto an empty frame buffer and don't need to do this
// We do need to do this with the battery though, as it is an "overlay"
fillRect(l, t, w, h, WHITE);
// Vertical centerline
const int16_t m = t + (h / 2);
fillRect(0, 0, width(), height(), WHITE);
// =====================
// Draw battery outline
// =====================
// Positive terminal "bump"
const int16_t &bumpL = l;
const uint16_t bumpH = h / 2;
const int16_t bumpT = m - (bumpH / 2);
constexpr uint16_t bumpW = 2;
const int16_t &bumpL = 1;
const uint16_t bumpH = (height() - 2) / 2;
const int16_t bumpT = (1 + ((height() - 2) / 2)) - (bumpH / 2);
fillRect(bumpL, bumpT, bumpW, bumpH, BLACK);
// Main body of battery
const int16_t bodyL = bumpL + bumpW;
const int16_t &bodyT = t;
const int16_t &bodyH = h;
const int16_t bodyW = w - bumpW;
const int16_t bodyL = 1 + bumpW;
const int16_t &bodyT = 1;
const int16_t &bodyH = height() - 2; // Handle top/bottom padding
const int16_t bodyW = (width() - 1) - bumpW; // Handle 1px left pad
drawRect(bodyL, bodyT, bodyW, bodyH, BLACK);
// Erase join between bump and body
@@ -87,15 +79,16 @@ void InkHUD::BatteryIconApplet::onRender()
// ===================
constexpr int16_t slicePad = 2;
const int16_t sliceL = bodyL + slicePad;
int16_t sliceL = bodyL + slicePad;
const int16_t sliceT = bodyT + slicePad;
const uint16_t sliceH = bodyH - (slicePad * 2);
uint16_t sliceW = bodyW - (slicePad * 2);
sliceW = (sliceW * socRounded) / 100; // Apply percentage
sliceW = (sliceW * socRounded) / 100; // Apply percentage
sliceL += ((bodyW - (slicePad * 2)) - sliceW); // Shift slice to the battery's negative terminal, correcting drain direction
hatchRegion(sliceL, sliceT, sliceW, sliceH, 2, BLACK);
drawRect(sliceL, sliceT, sliceW, sliceH, BLACK);
}
#endif
#endif
@@ -23,7 +23,7 @@ class BatteryIconApplet : public SystemApplet
public:
BatteryIconApplet();
void onRender() override;
void onRender(bool full) override;
int onPowerStatusUpdate(const meshtastic::Status *status); // Called when new info about battery is available
private:
@@ -0,0 +1,257 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./KeyboardApplet.h"
using namespace NicheGraphics;
InkHUD::KeyboardApplet::KeyboardApplet()
{
// Calculate row widths
for (uint8_t row = 0; row < KBD_ROWS; row++) {
rowWidths[row] = 0;
for (uint8_t col = 0; col < KBD_COLS; col++)
rowWidths[row] += keyWidths[row * KBD_COLS + col];
}
}
void InkHUD::KeyboardApplet::onRender(bool full)
{
uint16_t em = fontSmall.lineHeight(); // 16 pt
uint16_t keyH = Y(1.0) / KBD_ROWS;
int16_t keyTopPadding = (keyH - fontSmall.lineHeight()) / 2;
if (full) { // Draw full keyboard
for (uint8_t row = 0; row < KBD_ROWS; row++) {
// Calculate the remaining space to be used as padding
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
// Draw keys
uint16_t xPos = 0;
for (uint8_t col = 0; col < KBD_COLS; col++) {
Color fgcolor = BLACK;
uint8_t index = row * KBD_COLS + col;
uint16_t keyX = ((xPos * em) >> 4) + ((col * keyXPadding) / (KBD_COLS - 1));
uint16_t keyY = row * keyH;
uint16_t keyW = (keyWidths[index] * em) >> 4;
if (index == selectedKey) {
fgcolor = WHITE;
fillRect(keyX, keyY, keyW, keyH, BLACK);
}
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[index], fgcolor);
xPos += keyWidths[index];
}
}
} else { // Only draw the difference
if (selectedKey != prevSelectedKey) {
// Draw previously selected key
uint8_t row = prevSelectedKey / KBD_COLS;
int16_t keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
uint16_t xPos = 0;
for (uint8_t i = prevSelectedKey - (prevSelectedKey % KBD_COLS); i < prevSelectedKey; i++)
xPos += keyWidths[i];
uint16_t keyX = ((xPos * em) >> 4) + (((prevSelectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
uint16_t keyY = row * keyH;
uint16_t keyW = (keyWidths[prevSelectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, WHITE);
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[prevSelectedKey], BLACK);
// Draw newly selected key
row = selectedKey / KBD_COLS;
keyXPadding = X(1.0) - ((rowWidths[row] * em) >> 4);
xPos = 0;
for (uint8_t i = selectedKey - (selectedKey % KBD_COLS); i < selectedKey; i++)
xPos += keyWidths[i];
keyX = ((xPos * em) >> 4) + (((selectedKey % KBD_COLS) * keyXPadding) / (KBD_COLS - 1));
keyY = row * keyH;
keyW = (keyWidths[selectedKey] * em) >> 4;
fillRect(keyX, keyY, keyW, keyH, BLACK);
drawKeyLabel(keyX, keyY + keyTopPadding, keyW, keys[selectedKey], WHITE);
}
}
prevSelectedKey = selectedKey;
}
// Draw the key label corresponding to the char
// for most keys it draws the character itself
// for ['\b', '\n', ' ', '\x1b'] it draws special glyphs
void InkHUD::KeyboardApplet::drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color)
{
if (key == '\b') {
// Draw backspace glyph: 13 x 9 px
/**
* [][][][][][][][][]
* [][] []
* [][] [] [] []
* [][] [] [] []
* [][] [] []
* [][] [] [] []
* [][] [] [] []
* [][] []
* [][][][][][][][][]
*/
const uint8_t bsBitmap[] = {0x0f, 0xf8, 0x18, 0x08, 0x32, 0x28, 0x61, 0x48, 0xc0,
0x88, 0x61, 0x48, 0x32, 0x28, 0x18, 0x08, 0x0f, 0xf8};
uint16_t leftPadding = (width - 13) >> 1;
drawBitmap(left + leftPadding, top + 1, bsBitmap, 13, 9, color);
} else if (key == '\n') {
// Draw done glyph: 12 x 9 px
/**
* [][]
* [][]
* [][]
* [][]
* [][]
* [][] [][]
* [][] [][]
* [][][]
* []
*/
const uint8_t doneBitmap[] = {0x00, 0x30, 0x00, 0x60, 0x00, 0xc0, 0x01, 0x80, 0x03,
0x00, 0xc6, 0x00, 0x6c, 0x00, 0x38, 0x00, 0x10, 0x00};
uint16_t leftPadding = (width - 12) >> 1;
drawBitmap(left + leftPadding, top + 1, doneBitmap, 12, 9, color);
} else if (key == ' ') {
// Draw space glyph: 13 x 9 px
/**
*
*
*
*
* [] []
* [] []
* [][][][][][][][][][][][][]
*
*
*/
const uint8_t spaceBitmap[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80,
0x08, 0x80, 0x08, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00};
uint16_t leftPadding = (width - 13) >> 1;
drawBitmap(left + leftPadding, top + 1, spaceBitmap, 13, 9, color);
} else if (key == '\x1b') {
setTextColor(color);
std::string keyText = "ESC";
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
printAt(left + leftPadding, top, keyText);
} else {
setTextColor(color);
if (key >= 0x61)
key -= 32; // capitalize
std::string keyText = std::string(1, key);
uint16_t leftPadding = (width - getTextWidth(keyText)) >> 1;
printAt(left + leftPadding, top, keyText);
}
}
void InkHUD::KeyboardApplet::onForeground()
{
handleInput = true; // Intercept the button input for our applet
// Select the first key
selectedKey = 0;
prevSelectedKey = 0;
}
void InkHUD::KeyboardApplet::onBackground()
{
handleInput = false;
}
void InkHUD::KeyboardApplet::onButtonShortPress()
{
char key = keys[selectedKey];
if (key == '\n') {
inkhud->freeTextDone();
inkhud->closeKeyboard();
} else if (key == '\x1b') {
inkhud->freeTextCancel();
inkhud->closeKeyboard();
} else {
inkhud->freeText(key);
}
}
void InkHUD::KeyboardApplet::onButtonLongPress()
{
char key = keys[selectedKey];
if (key == '\n') {
inkhud->freeTextDone();
inkhud->closeKeyboard();
} else if (key == '\x1b') {
inkhud->freeTextCancel();
inkhud->closeKeyboard();
} else {
if (key >= 0x61)
key -= 32; // capitalize
inkhud->freeText(key);
}
}
void InkHUD::KeyboardApplet::onExitShort()
{
inkhud->freeTextCancel();
inkhud->closeKeyboard();
}
void InkHUD::KeyboardApplet::onExitLong()
{
inkhud->freeTextCancel();
inkhud->closeKeyboard();
}
void InkHUD::KeyboardApplet::onNavUp()
{
if (selectedKey < KBD_COLS) // wrap
selectedKey += KBD_COLS * (KBD_ROWS - 1);
else // move 1 row back
selectedKey -= KBD_COLS;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
void InkHUD::KeyboardApplet::onNavDown()
{
selectedKey += KBD_COLS;
selectedKey %= (KBD_COLS * KBD_ROWS);
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
void InkHUD::KeyboardApplet::onNavLeft()
{
if (selectedKey % KBD_COLS == 0) // wrap
selectedKey += KBD_COLS - 1;
else // move 1 column back
selectedKey--;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
void InkHUD::KeyboardApplet::onNavRight()
{
if (selectedKey % KBD_COLS == KBD_COLS - 1) // wrap
selectedKey -= KBD_COLS - 1;
else // move 1 column forward
selectedKey++;
// Request rendering over the previously drawn render
requestUpdate(EInk::UpdateTypes::FAST, false);
// Force an update to bypass lockRequests
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
uint16_t InkHUD::KeyboardApplet::getKeyboardHeight()
{
const uint16_t keyH = fontSmall.lineHeight() * 1.2;
return keyH * KBD_ROWS;
}
#endif
@@ -0,0 +1,66 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
/*
System Applet to render an on-screen keyboard
*/
#pragma once
#include "configuration.h"
#include "graphics/niche/InkHUD/InkHUD.h"
#include "graphics/niche/InkHUD/SystemApplet.h"
#include <string>
namespace NicheGraphics::InkHUD
{
class KeyboardApplet : public SystemApplet
{
public:
KeyboardApplet();
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
void onButtonShortPress() override;
void onButtonLongPress() override;
void onExitShort() override;
void onExitLong() override;
void onNavUp() override;
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
static uint16_t getKeyboardHeight(); // used to set the keyboard tile height
private:
void drawKeyLabel(uint16_t left, uint16_t top, uint16_t width, char key, Color color);
static const uint8_t KBD_COLS = 11;
static const uint8_t KBD_ROWS = 4;
const char keys[KBD_COLS * KBD_ROWS] = {
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b', // row 0
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n', // row 1
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '!', ' ', // row 2
'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '?', '\x1b' // row 3
};
// This array represents the widths of each key in points
// 16 pt = line height of the text
const uint16_t keyWidths[KBD_COLS * KBD_ROWS] = {
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 0
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 1
16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 24, // row 2
16, 16, 16, 16, 16, 16, 16, 10, 10, 12, 40 // row 3
};
uint16_t rowWidths[KBD_ROWS];
uint8_t selectedKey = 0; // selected key index
uint8_t prevSelectedKey = 0;
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -30,7 +30,7 @@ InkHUD::LogoApplet::LogoApplet() : concurrency::OSThread("LogoApplet")
// This is then drawn with a FULL refresh by Renderer::begin
}
void InkHUD::LogoApplet::onRender()
void InkHUD::LogoApplet::onRender(bool full)
{
// Size of the region which the logo should "scale to fit"
uint16_t logoWLimit = X(0.8);
@@ -45,7 +45,7 @@ void InkHUD::LogoApplet::onRender()
int16_t logoCY = Y(0.5 - 0.05);
// Invert colors if black-on-white
// Used during shutdown, to resport display health
// Used during shutdown, to report display health
// Todo: handle this in InkHUD::Renderer instead
if (inverted) {
fillScreen(BLACK);
@@ -120,7 +120,7 @@ void InkHUD::LogoApplet::onBackground()
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
// Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
inkhud->forceUpdate(EInk::UpdateTypes::FULL, true);
}
// Begin displaying the screen which is shown at shutdown
@@ -138,10 +138,10 @@ void InkHUD::LogoApplet::onShutdown()
// Intention is to restore display health.
inverted = true;
inkhud->forceUpdate(Drivers::EInk::FULL, false);
inkhud->forceUpdate(Drivers::EInk::FULL, true, false);
delay(1000); // Cooldown. Back to back updates aren't great for health.
inverted = false;
inkhud->forceUpdate(Drivers::EInk::FULL, false);
inkhud->forceUpdate(Drivers::EInk::FULL, true, false);
delay(1000); // Cooldown
// Prepare for the powered-off screen now
@@ -155,6 +155,18 @@ void InkHUD::LogoApplet::onShutdown()
// This is then drawn by InkHUD::Events::onShutdown, with a blocking FULL update, after InkHUD's flash write is complete
}
void InkHUD::LogoApplet::onApplyingChanges()
{
bringToForeground();
textLeft = "";
textRight = "";
textTitle = "Applying changes";
fontTitle = fontSmall;
inkhud->forceUpdate(Drivers::EInk::FAST, false);
}
void InkHUD::LogoApplet::onReboot()
{
bringToForeground();
@@ -164,7 +176,7 @@ void InkHUD::LogoApplet::onReboot()
textTitle = "Rebooting...";
fontTitle = fontSmall;
inkhud->forceUpdate(Drivers::EInk::FULL, false);
inkhud->forceUpdate(Drivers::EInk::FULL, true, false);
// Perform the update right now, waiting here until complete
}
@@ -174,4 +186,4 @@ int32_t InkHUD::LogoApplet::runOnce()
return OSThread::disable();
}
#endif
#endif
@@ -21,11 +21,12 @@ class LogoApplet : public SystemApplet, public concurrency::OSThread
{
public:
LogoApplet();
void onRender() override;
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
void onShutdown() override;
void onReboot() override;
void onApplyingChanges();
protected:
int32_t runOnce() override;
@@ -19,6 +19,7 @@ namespace NicheGraphics::InkHUD
enum MenuAction {
NO_ACTION,
SEND_PING,
FREE_TEXT,
STORE_CANNEDMESSAGE_SELECTION,
SEND_CANNEDMESSAGE,
SHUTDOWN,
@@ -36,6 +37,84 @@ enum MenuAction {
TOGGLE_NOTIFICATIONS,
TOGGLE_INVERT_COLOR,
TOGGLE_12H_CLOCK,
// Regions
SET_REGION_US,
SET_REGION_EU_868,
SET_REGION_EU_433,
SET_REGION_CN,
SET_REGION_JP,
SET_REGION_ANZ,
SET_REGION_KR,
SET_REGION_TW,
SET_REGION_RU,
SET_REGION_IN,
SET_REGION_NZ_865,
SET_REGION_TH,
SET_REGION_LORA_24,
SET_REGION_UA_433,
SET_REGION_UA_868,
SET_REGION_MY_433,
SET_REGION_MY_919,
SET_REGION_SG_923,
SET_REGION_PH_433,
SET_REGION_PH_868,
SET_REGION_PH_915,
SET_REGION_ANZ_433,
SET_REGION_KZ_433,
SET_REGION_KZ_863,
SET_REGION_NP_865,
SET_REGION_BR_902,
// Device Roles
SET_ROLE_CLIENT,
SET_ROLE_CLIENT_MUTE,
SET_ROLE_ROUTER,
SET_ROLE_REPEATER,
// Presets
SET_PRESET_LONG_SLOW,
SET_PRESET_LONG_MODERATE,
SET_PRESET_LONG_FAST,
SET_PRESET_MEDIUM_SLOW,
SET_PRESET_MEDIUM_FAST,
SET_PRESET_SHORT_SLOW,
SET_PRESET_SHORT_FAST,
SET_PRESET_SHORT_TURBO,
// Timezones
SET_TZ_US_HAWAII,
SET_TZ_US_ALASKA,
SET_TZ_US_PACIFIC,
SET_TZ_US_ARIZONA,
SET_TZ_US_MOUNTAIN,
SET_TZ_US_CENTRAL,
SET_TZ_US_EASTERN,
SET_TZ_BR_BRAZILIA,
SET_TZ_UTC,
SET_TZ_EU_WESTERN,
SET_TZ_EU_CENTRAL,
SET_TZ_EU_EASTERN,
SET_TZ_ASIA_KOLKATA,
SET_TZ_ASIA_HONG_KONG,
SET_TZ_AU_AWST,
SET_TZ_AU_ACST,
SET_TZ_AU_AEST,
SET_TZ_PACIFIC_NZ,
// Power
TOGGLE_POWER_SAVE,
CALIBRATE_ADC,
// Bluetooth
TOGGLE_BLUETOOTH,
TOGGLE_BLUETOOTH_PAIR_MODE,
// Channel
TOGGLE_CHANNEL_UPLINK,
TOGGLE_CHANNEL_DOWNLINK,
TOGGLE_CHANNEL_POSITION,
SET_CHANNEL_PRECISION,
// Display
TOGGLE_DISPLAY_UNITS,
// Network
TOGGLE_WIFI,
// Administration
RESET_NODEDB_ALL,
RESET_NODEDB_KEEP_FAVORITES,
};
} // namespace NicheGraphics::InkHUD
File diff suppressed because it is too large Load Diff
@@ -32,9 +32,13 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void onNavDown() override;
void onNavLeft() override;
void onNavRight() override;
void onRender() override;
void onFreeText(char c) override;
void onFreeTextDone() override;
void onFreeTextCancel() override;
void onRender(bool full) override;
void show(Tile *t); // Open the menu, onto a user tile
void setStartPage(MenuPage page);
protected:
Drivers::LatchingBacklight *backlight = nullptr; // Convenient access to the backlight singleton
@@ -50,20 +54,32 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
void populateAutoshowPage(); // Dynamically create MenuItems for selecting which applets can autoshow
void populateRecentsPage(); // Create menu items: a choice of values for settings.recentlyActiveSeconds
void drawInputField(uint16_t left, uint16_t top, uint16_t width, uint16_t height,
const std::string &text); // Draw input field for free text
uint16_t getSystemInfoPanelHeight();
void drawSystemInfoPanel(int16_t left, int16_t top, uint16_t width,
uint16_t *height = nullptr); // Info panel at top of root menu
void sendText(NodeNum dest, ChannelIndex channel, const char *message); // Send a text message to mesh
void freeCannedMessageResources(); // Clear MenuApplet's canned message processing data
MenuPage startPageOverride = MenuPage::ROOT;
MenuPage currentPage = MenuPage::ROOT;
MenuPage previousPage = MenuPage::EXIT;
uint8_t cursor = 0; // Which menu item is currently highlighted
bool cursorShown = false; // Is *any* item highlighted? (Root menu: no initial selection)
bool freeTextMode = false;
uint16_t systemInfoPanelHeight = 0; // Need to know before we render
uint16_t menuTextLimit = 200;
std::vector<MenuItem> items; // MenuItems for the current page. Filled by ShowPage
std::vector<MenuItem> items; // MenuItems for the current page. Filled by ShowPage
std::vector<std::string> nodeConfigLabels; // Persistent labels for Node Config pages
uint8_t selectedChannelIndex = 0; // Currently selected LoRa channel (Node Config → Radio → Channel)
bool channelPositionEnabled = false;
bool gpsEnabled = false;
// Recents menu checkbox state (derived from settings.recentlyActiveSeconds)
static constexpr uint8_t RECENTS_COUNT = 6;
bool recentsSelected[RECENTS_COUNT] = {};
// Data for selecting and sending canned messages via the menu
// Placed into a sub-class for organization only
@@ -94,6 +110,8 @@ class MenuApplet : public SystemApplet, public concurrency::OSThread
// Cleared onBackground (when MenuApplet closes)
std::vector<MessageItem> messageItems;
std::vector<RecipientItem> recipientItems;
MessageItem freeTextItem;
} cm;
Applet *borrowedTileOwner = nullptr; // Which applet we have temporarily replaced while displaying menu
@@ -30,6 +30,7 @@ class MenuItem
MenuAction action = NO_ACTION;
MenuPage nextPage = EXIT;
bool *checkState = nullptr;
bool isHeader = false; // Non-selectable section label
// Various constructors, depending on the intended function of the item
@@ -40,6 +41,12 @@ class MenuItem
: label(label), action(action), nextPage(nextPage), checkState(checkState)
{
}
static MenuItem Header(const char *label)
{
MenuItem item(label, NO_ACTION, EXIT);
item.isHeader = true;
return item;
}
};
} // namespace NicheGraphics::InkHUD
@@ -20,10 +20,27 @@ enum MenuPage : uint8_t {
SEND,
CANNEDMESSAGE_RECIPIENT, // Select destination for a canned message
OPTIONS,
NODE_CONFIG,
NODE_CONFIG_LORA,
NODE_CONFIG_CHANNELS, // List of channels
NODE_CONFIG_CHANNEL_DETAIL, // Per-channel options
NODE_CONFIG_CHANNEL_PRECISION,
NODE_CONFIG_PRESET,
NODE_CONFIG_DEVICE,
NODE_CONFIG_DEVICE_ROLE,
NODE_CONFIG_POWER,
NODE_CONFIG_POWER_ADC_CAL,
NODE_CONFIG_NETWORK,
NODE_CONFIG_DISPLAY,
NODE_CONFIG_BLUETOOTH,
NODE_CONFIG_POSITION,
NODE_CONFIG_ADMIN_RESET,
TIMEZONE,
APPLETS,
AUTOSHOW,
RECENTS, // Select length of "recentlyActiveSeconds"
EXIT, // Dismiss the menu applet
REGION,
EXIT, // Dismiss the menu applet
};
} // namespace NicheGraphics::InkHUD
@@ -65,7 +65,7 @@ int InkHUD::NotificationApplet::onReceiveTextMessage(const meshtastic_MeshPacket
return 0;
}
void InkHUD::NotificationApplet::onRender()
void InkHUD::NotificationApplet::onRender(bool full)
{
// Clear the region beneath the tile
// Most applets are drawing onto an empty frame buffer and don't need to do this
@@ -139,54 +139,47 @@ void InkHUD::NotificationApplet::onForeground()
void InkHUD::NotificationApplet::onBackground()
{
handleInput = false;
inkhud->forceUpdate(EInk::UpdateTypes::FULL, true);
}
void InkHUD::NotificationApplet::onButtonShortPress()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onButtonLongPress()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onExitShort()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onExitLong()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onNavUp()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onNavDown()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onNavLeft()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
void InkHUD::NotificationApplet::onNavRight()
{
dismiss();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
// Ask the WindowManager to check whether any displayed applets are already displaying the info from this notification
@@ -235,17 +228,17 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
Notification::Type::NOTIFICATION_MESSAGE_BROADCAST)) {
// Although we are handling DM and broadcast notifications together, we do need to treat them slightly differently
bool isBroadcast = currentNotification.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST;
bool msgIsBroadcast = currentNotification.type == Notification::Type::NOTIFICATION_MESSAGE_BROADCAST;
// Pick source of message
MessageStore::Message *message =
isBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
const MessageStore::Message *message =
msgIsBroadcast ? &inkhud->persistence->latestMessage.broadcast : &inkhud->persistence->latestMessage.dm;
// Find info about the sender
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(message->sender);
// Leading tag (channel vs. DM)
text += isBroadcast ? "From:" : "DM: ";
text += msgIsBroadcast ? "From:" : "DM: ";
// Sender id
if (node && node->has_user)
@@ -259,7 +252,7 @@ std::string InkHUD::NotificationApplet::getNotificationText(uint16_t widthAvaila
text.clear();
// Leading tag (channel vs. DM)
text += isBroadcast ? "Msg from " : "DM from ";
text += msgIsBroadcast ? "Msg from " : "DM from ";
// Sender id
if (node && node->has_user)
@@ -26,7 +26,7 @@ class NotificationApplet : public SystemApplet
public:
NotificationApplet();
void onRender() override;
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
void onButtonShortPress() override;
@@ -9,7 +9,7 @@ InkHUD::PairingApplet::PairingApplet()
bluetoothStatusObserver.observe(&bluetoothStatus->onNewStatus);
}
void InkHUD::PairingApplet::onRender()
void InkHUD::PairingApplet::onRender(bool full)
{
// Header
setFont(fontMedium);
@@ -45,7 +45,7 @@ void InkHUD::PairingApplet::onBackground()
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
// Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
inkhud->forceUpdate(EInk::UpdateTypes::FULL, true);
}
int InkHUD::PairingApplet::onBluetoothStatusUpdate(const meshtastic::Status *status)
@@ -55,12 +55,12 @@ int InkHUD::PairingApplet::onBluetoothStatusUpdate(const meshtastic::Status *sta
// We'll mimic that behavior, just to keep in line with the other Statuses,
// even though I'm not sure what the original reason for jumping through these extra hoops was.
assert(status->getStatusType() == STATUS_TYPE_BLUETOOTH);
meshtastic::BluetoothStatus *bluetoothStatus = (meshtastic::BluetoothStatus *)status;
const auto *btStatus = static_cast<const meshtastic::BluetoothStatus *>(status);
// When pairing begins
if (bluetoothStatus->getConnectionState() == meshtastic::BluetoothStatus::ConnectionState::PAIRING) {
if (btStatus->getConnectionState() == meshtastic::BluetoothStatus::ConnectionState::PAIRING) {
// Store the passkey for rendering
passkey = bluetoothStatus->getPasskey();
passkey = btStatus->getPasskey();
// Show pairing screen
bringToForeground();
@@ -22,7 +22,7 @@ class PairingApplet : public SystemApplet
public:
PairingApplet();
void onRender() override;
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
@@ -4,7 +4,7 @@
using namespace NicheGraphics;
void InkHUD::PlaceholderApplet::onRender()
void InkHUD::PlaceholderApplet::onRender(bool full)
{
// This placeholder applet fills its area with sparse diagonal lines
hatchRegion(0, 0, width(), height(), 8, BLACK);
@@ -17,7 +17,7 @@ namespace NicheGraphics::InkHUD
class PlaceholderApplet : public SystemApplet
{
public:
void onRender() override;
void onRender(bool full) override;
// Note: onForeground, onBackground, and wantsToRender are not meaningful for this applet.
// The window manager decides when and where it should be rendered
@@ -10,39 +10,42 @@ using namespace NicheGraphics;
InkHUD::TipsApplet::TipsApplet()
{
// Decide which tips (if any) should be shown to user after the boot screen
bool needsRegion = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
bool showTutorialTips = (settings->tips.firstBoot || needsRegion);
// Welcome screen
if (settings->tips.firstBoot)
if (showTutorialTips)
tipQueue.push_back(Tip::WELCOME);
// Antenna, region, timezone
// Shown at boot if region not yet set
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
// Finish setup
if (needsRegion)
tipQueue.push_back(Tip::FINISH_SETUP);
// Using the UI
if (showTutorialTips) {
tipQueue.push_back(Tip::CUSTOMIZATION);
tipQueue.push_back(Tip::BUTTONS);
}
// Shutdown info
// Shown until user performs one valid shutdown
if (!settings->tips.safeShutdownSeen)
tipQueue.push_back(Tip::SAFE_SHUTDOWN);
// Using the UI
if (settings->tips.firstBoot) {
tipQueue.push_back(Tip::CUSTOMIZATION);
tipQueue.push_back(Tip::BUTTONS);
}
// Catch an incorrect attempt at rotating display
if (config.display.flip_screen)
tipQueue.push_back(Tip::ROTATION);
// Applet is foreground immediately at boot, but is obscured by LogoApplet, which is also foreground
// LogoApplet can be considered to have a higher Z-index, because it is placed before TipsApplet in the systemApplets vector
// Region picker
if (needsRegion)
tipQueue.push_back(Tip::PICK_REGION);
if (!tipQueue.empty())
bringToForeground();
}
void InkHUD::TipsApplet::onRender()
void InkHUD::TipsApplet::onRender(bool full)
{
switch (tipQueue.front()) {
case Tip::WELCOME:
@@ -51,81 +54,114 @@ void InkHUD::TipsApplet::onRender()
case Tip::FINISH_SETUP: {
setFont(fontMedium);
printAt(0, 0, "Tip: Finish Setup");
const char *title = "Tip: Finish Setup";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
int16_t cursorY = fontMedium.lineHeight() * 1.5;
printAt(0, cursorY, "- connect antenna");
int16_t cursorY = h + fontSmall.lineHeight();
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- connect a client app");
auto drawBullet = [&](const char *text) {
uint16_t bh = getWrappedTextHeight(0, width(), text);
printWrapped(0, cursorY, width(), text);
cursorY += bh + (fontSmall.lineHeight() / 3);
};
// Only if region not set
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- set region");
}
drawBullet("- connect antenna");
drawBullet("- connect a client app");
// Only if tz not set
if (!(*config.device.tzdef && config.device.tzdef[0] != 0)) {
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- set timezone");
}
if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET)
drawBullet("- set region");
cursorY += fontSmall.lineHeight() * 1.5;
printAt(0, cursorY, "More info at meshtastic.org");
if (!(*config.device.tzdef && config.device.tzdef[0] != 0))
drawBullet("- set timezone");
cursorY += fontSmall.lineHeight() / 2;
drawBullet("More info at meshtastic.org");
setFont(fontSmall);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
} break;
case Tip::PICK_REGION: {
setFont(fontMedium);
printAt(0, 0, "Set Region");
setFont(fontSmall);
printWrapped(0, fontMedium.lineHeight() * 1.5, width(), "Please select your LoRa region to complete setup.");
printAt(0, Y(1.0), "Press button to choose", LEFT, BOTTOM);
} break;
case Tip::SAFE_SHUTDOWN: {
setFont(fontMedium);
printAt(0, 0, "Tip: Shutdown");
const char *title = "Tip: Shutdown";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
std::string shutdown;
shutdown += "Before removing power, please shut down from InkHUD menu, or a client app. \n";
shutdown += "\n";
shutdown += "This ensures data is saved.";
printWrapped(0, fontMedium.lineHeight() * 1.5, width(), shutdown);
int16_t cursorY = h + fontSmall.lineHeight();
const char *body = "Before removing power, please shut down from InkHUD menu, or a client app.\n\n"
"This ensures data is saved.";
uint16_t bodyH = getWrappedTextHeight(0, width(), body);
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
} break;
case Tip::CUSTOMIZATION: {
setFont(fontMedium);
printAt(0, 0, "Tip: Customization");
const char *title = "Tip: Customization";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
printWrapped(0, fontMedium.lineHeight() * 1.5, width(),
"Configure & control display with the InkHUD menu. Optional features, layout, rotation, and more.");
int16_t cursorY = h + fontSmall.lineHeight();
const char *body = "Configure & control display with the InkHUD menu. "
"Optional features, layout, rotation, and more.";
uint16_t bodyH = getWrappedTextHeight(0, width(), body);
printWrapped(0, cursorY, width(), body);
cursorY += bodyH + (fontSmall.lineHeight() / 2);
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
} break;
case Tip::BUTTONS: {
setFont(fontMedium);
printAt(0, 0, "Tip: Buttons");
const char *title = "Tip: Buttons";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
int16_t cursorY = fontMedium.lineHeight() * 1.5;
int16_t cursorY = h + fontSmall.lineHeight();
auto drawBullet = [&](const char *text) {
uint16_t bh = getWrappedTextHeight(0, width(), text);
printWrapped(0, cursorY, width(), text);
cursorY += bh + (fontSmall.lineHeight() / 3);
};
if (!settings->joystick.enabled) {
printAt(0, cursorY, "User Button");
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- short press: next");
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- long press: select / open menu");
drawBullet("User Button");
drawBullet("- short press: next");
drawBullet("- long press: select or open menu");
} else if (inkhud->twoWayRocker) {
drawBullet("Rocker + Button");
drawBullet("- center press: open menu or select");
drawBullet("- left/right: applet nav");
drawBullet("- in menu: up/down");
} else {
printAt(0, cursorY, "Joystick");
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- open menu / select");
cursorY += fontSmall.lineHeight() * 1.5;
printAt(0, cursorY, "Exit Button");
cursorY += fontSmall.lineHeight() * 1.2;
printAt(0, cursorY, "- switch tile / close menu");
drawBullet("Joystick");
drawBullet("- press: open menu or select");
drawBullet("Exit Button");
drawBullet("- press: switch tile or close menu");
}
printAt(0, Y(1.0), "Press button to continue", LEFT, BOTTOM);
@@ -133,12 +169,21 @@ void InkHUD::TipsApplet::onRender()
case Tip::ROTATION: {
setFont(fontMedium);
printAt(0, 0, "Tip: Rotation");
const char *title = "Tip: Rotation";
uint16_t h = getWrappedTextHeight(0, width(), title);
printWrapped(0, 0, width(), title);
setFont(fontSmall);
if (!settings->joystick.enabled) {
printWrapped(0, fontMedium.lineHeight() * 1.5, width(),
"To rotate the display, use the InkHUD menu. Long-press the user button > Options > Rotate.");
int16_t cursorY = h + fontSmall.lineHeight();
const char *body = "To rotate the display, use the InkHUD menu. "
"Long-press the user button > Options > Rotate.";
uint16_t bh = getWrappedTextHeight(0, width(), body);
printWrapped(0, cursorY, width(), body);
cursorY += bh + (fontSmall.lineHeight() / 2);
} else {
printWrapped(0, fontMedium.lineHeight() * 1.5, width(),
"To rotate the display, use the InkHUD menu. Press the user button > Options > Rotate.");
@@ -159,12 +204,15 @@ void InkHUD::TipsApplet::renderWelcome()
{
uint16_t padW = X(0.05);
// Detect portrait orientation
bool portrait = height() > width();
// Block 1 - logo & title
// ========================
// Logo size
uint16_t logoWLimit = X(0.3);
uint16_t logoHLimit = Y(0.3);
uint16_t logoWLimit = portrait ? X(0.5) : X(0.3);
uint16_t logoHLimit = portrait ? Y(0.25) : Y(0.3);
uint16_t logoW = getLogoWidth(logoWLimit, logoHLimit);
uint16_t logoH = getLogoHeight(logoWLimit, logoHLimit);
@@ -177,7 +225,7 @@ void InkHUD::TipsApplet::renderWelcome()
// Center the block
// Desired effect: equal margin from display edge for logo left and title right
int16_t block1Y = Y(0.3);
int16_t block1Y = portrait ? Y(0.2) : Y(0.3);
int16_t block1CX = X(0.5) + (logoW / 2) - (titleW / 2);
int16_t logoCX = block1CX - (logoW / 2) - (padW / 2);
int16_t titleCX = block1CX + (titleW / 2) + (padW / 2);
@@ -192,7 +240,7 @@ void InkHUD::TipsApplet::renderWelcome()
std::string subtitle = "InkHUD";
if (width() >= 200)
subtitle += " - A Heads-Up Display"; // Future proofing: narrower for tiny display
printAt(X(0.5), Y(0.6), subtitle, CENTER, MIDDLE);
printAt(X(0.5), portrait ? Y(0.45) : Y(0.6), subtitle, CENTER, MIDDLE);
// Block 3 - press to continue
// ============================
@@ -218,32 +266,42 @@ void InkHUD::TipsApplet::onBackground()
// Need to force an update, as a polite request wouldn't be honored, seeing how we are now in the background
// Usually, onBackground is followed by another applet's onForeground (which requests update), but not in this case
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
inkhud->forceUpdate(EInk::UpdateTypes::FULL, true);
}
// While our SystemApplet::handleInput flag is true
void InkHUD::TipsApplet::onButtonShortPress()
{
bool needsRegion = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
// If we're prompting the user to pick a region, hand off to the menu
if (!tipQueue.empty() && tipQueue.front() == Tip::PICK_REGION) {
tipQueue.pop_front();
// Signal InkHUD to open the menu on Region page
inkhud->forceRegionMenu = true;
// Close tips and open menu
sendToBackground();
inkhud->openMenu();
return;
}
// Consume current tip
tipQueue.pop_front();
// All tips done
if (tipQueue.empty()) {
// Record that user has now seen the "tutorial" set of tips
// Don't show them on subsequent boots
if (settings->tips.firstBoot) {
if (settings->tips.firstBoot && !needsRegion) {
settings->tips.firstBoot = false;
inkhud->persistence->saveSettings();
}
// Close applet, and full refresh to clean the screen
// Need to force update, because our request would be ignored otherwise, as we are now background
// Close applet
sendToBackground();
inkhud->forceUpdate(EInk::UpdateTypes::FULL);
}
// More tips left
else
} else {
requestUpdate();
}
}
// Functions the same as the user button in this instance
@@ -252,4 +310,4 @@ void InkHUD::TipsApplet::onExitShort()
onButtonShortPress();
}
#endif
#endif
@@ -23,6 +23,7 @@ class TipsApplet : public SystemApplet
enum class Tip {
WELCOME,
FINISH_SETUP,
PICK_REGION,
SAFE_SHUTDOWN,
CUSTOMIZATION,
BUTTONS,
@@ -32,7 +33,7 @@ class TipsApplet : public SystemApplet
public:
TipsApplet();
void onRender() override;
void onRender(bool full) override;
void onForeground() override;
void onBackground() override;
void onButtonShortPress() override;
@@ -34,7 +34,7 @@ int InkHUD::AllMessageApplet::onReceiveTextMessage(const meshtastic_MeshPacket *
return 0;
}
void InkHUD::AllMessageApplet::onRender()
void InkHUD::AllMessageApplet::onRender(bool full)
{
// Find newest message, regardless of whether DM or broadcast
MessageStore::Message *message;
@@ -30,7 +30,7 @@ class Applet;
class AllMessageApplet : public Applet
{
public:
void onRender() override;
void onRender(bool full) override;
void onActivate() override;
void onDeactivate() override;
@@ -37,7 +37,7 @@ int InkHUD::DMApplet::onReceiveTextMessage(const meshtastic_MeshPacket *p)
return 0;
}
void InkHUD::DMApplet::onRender()
void InkHUD::DMApplet::onRender(bool full)
{
// Abort if no text message
if (!latestMessage->dm.sender) {
@@ -30,7 +30,7 @@ class Applet;
class DMApplet : public Applet
{
public:
void onRender() override;
void onRender(bool full) override;
void onActivate() override;
void onDeactivate() override;
@@ -0,0 +1,111 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
#include "./FavoritesMapApplet.h"
#include "NodeDB.h"
using namespace NicheGraphics;
bool InkHUD::FavoritesMapApplet::shouldDrawNode(meshtastic_NodeInfoLite *node)
{
// Keep our own node available as map anchor/center; all others must be favorited.
return node && (node->num == nodeDB->getNodeNum() || node->is_favorite);
}
void InkHUD::FavoritesMapApplet::onRender(bool full)
{
// Custom empty state text for favorites-only map.
if (!enoughMarkers()) {
printAt(X(0.5), Y(0.5) - (getFont().lineHeight() / 2), "Favorite node position", CENTER, MIDDLE);
printAt(X(0.5), Y(0.5) + (getFont().lineHeight() / 2), "will appear here", CENTER, MIDDLE);
return;
}
// Draw the usual map applet first.
MapApplet::onRender(full);
// Draw our latest "node of interest" as a special marker.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(lastFrom);
if (node && node->is_favorite && nodeDB->hasValidPosition(node) && enoughMarkers())
drawLabeledMarker(node);
}
// Determine if we need to redraw the map, when we receive a new position packet.
ProcessMessage InkHUD::FavoritesMapApplet::handleReceived(const meshtastic_MeshPacket &mp)
{
// If applet is not active, we shouldn't be handling any data.
if (!isActive())
return ProcessMessage::CONTINUE;
// Try decode a position from the packet.
bool hasPosition = false;
float lat;
float lng;
if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) {
meshtastic_Position position = meshtastic_Position_init_default;
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &position)) {
if (position.has_latitude_i && position.has_longitude_i // Actually has position
&& (position.latitude_i != 0 || position.longitude_i != 0)) // Position isn't "null island"
{
hasPosition = true;
lat = position.latitude_i * 1e-7; // Convert from Meshtastic's internal int32_t format
lng = position.longitude_i * 1e-7;
}
}
}
// Skip if we didn't get a valid position.
if (!hasPosition)
return ProcessMessage::CONTINUE;
const int8_t hopsAway = getHopsAway(mp);
const bool hasHopsAway = hopsAway >= 0;
// Determine if the position packet would change anything on-screen.
bool somethingChanged = false;
// If our own position.
if (isFromUs(&mp)) {
// Ignore tiny local movement to reduce update spam.
if (GeoCoord::latLongToMeter(ourLastLat, ourLastLng, lat, lng) > 50) {
somethingChanged = true;
ourLastLat = lat;
ourLastLng = lng;
}
} else {
// For non-local packets, this applet only reacts to favorited nodes.
const meshtastic_NodeInfoLite *sender = nodeDB->getMeshNode(mp.from);
if (!sender || !sender->is_favorite)
return ProcessMessage::CONTINUE;
// Check if this position is from someone different than our previous position packet.
if (mp.from != lastFrom) {
somethingChanged = true;
lastFrom = mp.from;
lastLat = lat;
lastLng = lng;
lastHopsAway = hopsAway;
}
// Same sender: check if position changed.
else if (GeoCoord::latLongToMeter(lastLat, lastLng, lat, lng) > 10) {
somethingChanged = true;
lastLat = lat;
lastLng = lng;
}
// Same sender, same position: check if hops changed.
else if (hasHopsAway && (hopsAway != lastHopsAway)) {
somethingChanged = true;
lastHopsAway = hopsAway;
}
}
if (somethingChanged) {
requestAutoshow();
requestUpdate();
}
return ProcessMessage::CONTINUE;
}
#endif
@@ -0,0 +1,44 @@
#ifdef MESHTASTIC_INCLUDE_INKHUD
/*
Plots position of favorited nodes from DB, with North facing up.
Scaled to fit the most distant node.
Size of marker represents hops away.
The favorite node which most recently sent a position will be labeled.
*/
#pragma once
#include "configuration.h"
#include "graphics/niche/InkHUD/Applets/Bases/Map/MapApplet.h"
#include "SinglePortModule.h"
namespace NicheGraphics::InkHUD
{
class FavoritesMapApplet : public MapApplet, public SinglePortModule
{
public:
FavoritesMapApplet() : SinglePortModule("FavoritesMapApplet", meshtastic_PortNum_POSITION_APP) {}
void onRender(bool full) override;
protected:
bool shouldDrawNode(meshtastic_NodeInfoLite *node) override;
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
NodeNum lastFrom = 0; // Sender of most recent favorited (non-local) position packet
float lastLat = 0.0;
float lastLng = 0.0;
float lastHopsAway = 0;
float ourLastLat = 0.0; // Info about most recent local position
float ourLastLng = 0.0;
};
} // namespace NicheGraphics::InkHUD
#endif
@@ -69,9 +69,10 @@ void InkHUD::HeardApplet::populateFromNodeDB()
}
// Sort the collection by age
std::sort(ordered.begin(), ordered.end(), [](meshtastic_NodeInfoLite *top, meshtastic_NodeInfoLite *bottom) -> bool {
return (top->last_heard > bottom->last_heard);
});
std::sort(ordered.begin(), ordered.end(),
[](const meshtastic_NodeInfoLite *top, const meshtastic_NodeInfoLite *bottom) -> bool {
return (top->last_heard > bottom->last_heard);
});
// Keep the most recent entries only
// Just enough to fill the screen
@@ -5,10 +5,10 @@
using namespace NicheGraphics;
void InkHUD::PositionsApplet::onRender()
void InkHUD::PositionsApplet::onRender(bool full)
{
// Draw the usual map applet first
MapApplet::onRender();
MapApplet::onRender(full);
// Draw our latest "node of interest" as a special marker
// -------------------------------------------------------
@@ -24,7 +24,7 @@ class PositionsApplet : public MapApplet, public SinglePortModule
{
public:
PositionsApplet() : SinglePortModule("PositionsApplet", meshtastic_PortNum_POSITION_APP) {}
void onRender() override;
void onRender(bool full) override;
protected:
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
@@ -22,7 +22,7 @@ InkHUD::ThreadedMessageApplet::ThreadedMessageApplet(uint8_t channelIndex)
store = new MessageStore("ch" + to_string(channelIndex));
}
void InkHUD::ThreadedMessageApplet::onRender()
void InkHUD::ThreadedMessageApplet::onRender(bool full)
{
// =============
// Draw a header
@@ -69,7 +69,7 @@ void InkHUD::ThreadedMessageApplet::onRender()
while (msgB >= (0 - fontSmall.lineHeight()) && i < store->messages.size()) {
// Grab data for message
MessageStore::Message &m = store->messages.at(i);
const MessageStore::Message &m = store->messages.at(i);
bool outgoing = (m.sender == 0) || (m.sender == myNodeInfo.my_node_num); // Own NodeNum if canned message
std::string bodyText = parse(m.text); // Parse any non-ascii chars in the message
@@ -7,7 +7,7 @@ Displays a thread-view of incoming and outgoing message for a specific channel
The channel for this applet is set in the constructor,
when the applet is added to WindowManager in the setupNicheGraphics method.
Several messages are saved to flash at shutdown, to preseve applet between reboots.
Several messages are saved to flash at shutdown, to preserve applet between reboots.
This class has its own internal method for saving and loading to fs, which interacts directly with the FSCommon layer.
If the amount of flash usage is unacceptable, we could keep these in RAM only.
@@ -36,7 +36,7 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
explicit ThreadedMessageApplet(uint8_t channelIndex);
ThreadedMessageApplet() = delete;
void onRender() override;
void onRender(bool full) override;
void onActivate() override;
void onDeactivate() override;
@@ -55,4 +55,4 @@ class ThreadedMessageApplet : public Applet, public SinglePortModule
} // namespace NicheGraphics::InkHUD
#endif
#endif
+104 -13
View File
@@ -59,10 +59,16 @@ void InkHUD::Events::onButtonShort()
if (consumer) {
consumer->onButtonShortPress();
} else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
if (!settings->joystick.enabled)
inkhud->nextApplet();
else
inkhud->openMenu();
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::BUTTON_SHORT))
userConsumer->onButtonShortPress();
else {
if (!settings->joystick.enabled)
inkhud->nextApplet();
else
inkhud->openMenu();
}
}
}
@@ -84,8 +90,14 @@ void InkHUD::Events::onButtonLong()
// If no system applet is handling input, default behavior instead is to open the menu
if (consumer)
consumer->onButtonLongPress();
else
inkhud->openMenu();
else {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::BUTTON_LONG))
userConsumer->onButtonLongPress();
else
inkhud->openMenu();
}
}
void InkHUD::Events::onExitShort()
@@ -110,8 +122,14 @@ void InkHUD::Events::onExitShort()
// If no system applet is handling input, default behavior instead is change tiles
if (consumer)
consumer->onExitShort();
else if (!dismissedExt) // Don't change tile if this button press silenced the external notification module
inkhud->nextTile();
else if (!dismissedExt) { // Don't change tile if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_SHORT))
userConsumer->onExitShort();
else
inkhud->nextTile();
}
}
}
@@ -133,6 +151,13 @@ void InkHUD::Events::onExitLong()
if (consumer)
consumer->onExitLong();
else {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::EXIT_LONG))
userConsumer->onExitLong();
// Nothing uses exit long yet
}
}
}
@@ -157,6 +182,12 @@ void InkHUD::Events::onNavUp()
if (consumer)
consumer->onNavUp();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_UP))
userConsumer->onNavUp();
}
}
}
@@ -181,6 +212,12 @@ void InkHUD::Events::onNavDown()
if (consumer)
consumer->onNavDown();
else if (!dismissedExt) {
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_DOWN))
userConsumer->onNavDown();
}
}
}
@@ -206,8 +243,14 @@ void InkHUD::Events::onNavLeft()
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavLeft();
else if (!dismissedExt) // Don't change applet if this button press silenced the external notification module
inkhud->prevApplet();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_LEFT))
userConsumer->onNavLeft();
else
inkhud->prevApplet();
}
}
}
@@ -233,8 +276,47 @@ void InkHUD::Events::onNavRight()
// If no system applet is handling input, default behavior instead is to cycle applets
if (consumer)
consumer->onNavRight();
else if (!dismissedExt) // Don't change applet if this button press silenced the external notification module
inkhud->nextApplet();
else if (!dismissedExt) { // Don't change applet if this button press silenced the external notification module
Applet *userConsumer = inkhud->getActiveApplet();
if (userConsumer != nullptr && userConsumer->isInputSubscribed(Applet::NAV_RIGHT))
userConsumer->onNavRight();
else
inkhud->nextApplet();
}
}
}
void InkHUD::Events::onFreeText(char c)
{
// Trigger the first system applet that wants to handle the new character
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
sa->onFreeText(c);
break;
}
}
}
void InkHUD::Events::onFreeTextDone()
{
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
sa->onFreeTextDone();
break;
}
}
}
void InkHUD::Events::onFreeTextCancel()
{
// Trigger the first system applet that wants to handle it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
sa->onFreeTextCancel();
break;
}
}
}
@@ -266,7 +348,7 @@ int InkHUD::Events::beforeDeepSleep(void *unused)
// then prepared a final powered-off screen for us, which shows device shortname.
// We're updating to show that one now.
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, false);
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true, false);
delay(1000); // Cooldown, before potentially yanking display power
// InkHUD shutdown complete
@@ -276,6 +358,15 @@ int InkHUD::Events::beforeDeepSleep(void *unused)
return 0; // We agree: deep sleep now
}
// Display an intermediate screen while configuration changes are applied
void InkHUD::Events::applyingChanges()
{
// Bring the logo applet forward with a temporary message
for (SystemApplet *sa : inkhud->systemApplets) {
sa->onApplyingChanges();
}
}
// Callback for rebootObserver
// Same as shutdown, without drawing the logoApplet
// Makes sure we don't lose message history / InkHUD config
+12 -6
View File
@@ -29,12 +29,18 @@ class Events
void onButtonShort(); // User button: short press
void onButtonLong(); // User button: long press
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
void applyingChanges();
void onExitShort(); // Exit button: short press
void onExitLong(); // Exit button: long press
void onNavUp(); // Navigate up
void onNavDown(); // Navigate down
void onNavLeft(); // Navigate left
void onNavRight(); // Navigate right
// Free text typing events
void onFreeText(char c); // New freetext character input
void onFreeTextDone();
void onFreeTextCancel();
int beforeDeepSleep(void *unused); // Prepare for shutdown
int beforeReboot(void *unused); // Prepare for reboot
+47 -2
View File
@@ -53,6 +53,13 @@ void InkHUD::InkHUD::addApplet(const char *name, Applet *a, bool defaultActive,
windowManager->addApplet(name, a, defaultActive, defaultAutoshow, onTile);
}
void InkHUD::InkHUD::notifyApplyingChanges()
{
if (events) {
events->applyingChanges();
}
}
// Start InkHUD!
// Call this only after you have configured InkHUD
void InkHUD::InkHUD::begin()
@@ -168,6 +175,25 @@ void InkHUD::InkHUD::navRight()
}
}
// Call this for keyboard input
// The Keyboard Applet also calls this
void InkHUD::InkHUD::freeText(char c)
{
events->onFreeText(c);
}
// Call this to complete a freetext input
void InkHUD::InkHUD::freeTextDone()
{
events->onFreeTextDone();
}
// Call this to cancel a freetext input
void InkHUD::InkHUD::freeTextCancel()
{
events->onFreeTextCancel();
}
// Cycle the next user applet to the foreground
// Only activated applets are cycled
// If user has a multi-applet layout, the applets will cycle on the "focused tile"
@@ -184,6 +210,12 @@ void InkHUD::InkHUD::prevApplet()
windowManager->prevApplet();
}
// Returns the currently active applet
InkHUD::Applet *InkHUD::InkHUD::getActiveApplet()
{
return windowManager->getActiveApplet();
}
// Show the menu (on the the focused tile)
// The applet previously displayed there will be restored once the menu closes
void InkHUD::InkHUD::openMenu()
@@ -197,6 +229,18 @@ void InkHUD::InkHUD::openAlignStick()
windowManager->openAlignStick();
}
// Open the on-screen keyboard
void InkHUD::InkHUD::openKeyboard()
{
windowManager->openKeyboard();
}
// Close the on-screen keyboard
void InkHUD::InkHUD::closeKeyboard()
{
windowManager->closeKeyboard();
}
// In layouts where multiple applets are shown at once, change which tile is focused
// The focused tile in the one which cycles applets on button short press, and displays menu on long press
void InkHUD::InkHUD::nextTile()
@@ -245,10 +289,11 @@ void InkHUD::InkHUD::requestUpdate()
// Ignores all diplomacy:
// - the display *will* update
// - the specified update type *will* be used
// If the all parameter is true, the whole screen buffer is cleared and re-rendered
// If the async parameter is false, code flow is blocked while the update takes place
void InkHUD::InkHUD::forceUpdate(EInk::UpdateTypes type, bool async)
void InkHUD::InkHUD::forceUpdate(EInk::UpdateTypes type, bool all, bool async)
{
renderer->forceUpdate(type, async);
renderer->forceUpdate(type, all, async);
}
// Wait for any in-progress display update to complete before continuing
+18 -2
View File
@@ -47,6 +47,7 @@ class InkHUD
void setDriver(Drivers::EInk *driver);
void setDisplayResilience(uint8_t fastPerFull = 5, float stressMultiplier = 2.0);
void addApplet(const char *name, Applet *a, bool defaultActive = false, bool defaultAutoshow = false, uint8_t onTile = -1);
void notifyApplyingChanges();
void begin();
@@ -62,25 +63,40 @@ class InkHUD
void navLeft();
void navRight();
// Freetext handlers
void freeText(char c);
void freeTextDone();
void freeTextCancel();
// Trigger UI changes
// - called by various InkHUD components
// - suitable(?) for use by aux button, connected in variant nicheGraphics.h
void nextApplet();
void prevApplet();
NicheGraphics::InkHUD::Applet *getActiveApplet();
void openMenu();
void openAlignStick();
void openKeyboard();
void closeKeyboard();
void nextTile();
void prevTile();
void rotate();
void rotateJoystick(uint8_t angle = 1); // rotate 90 deg by default
void toggleBatteryIcon();
// Used by TipsApplet to force menu to start on Region selection
bool forceRegionMenu = false;
// Input mode hint for devices that use a left/right rocker plus center button
bool twoWayRocker = false;
// Updating the display
// - called by various InkHUD components
void requestUpdate();
void forceUpdate(Drivers::EInk::UpdateTypes type = Drivers::EInk::UpdateTypes::UNSPECIFIED, bool async = true);
void forceUpdate(Drivers::EInk::UpdateTypes type = Drivers::EInk::UpdateTypes::UNSPECIFIED, bool all = false,
bool async = true);
void awaitUpdate();
// (Re)configuring WindowManager
@@ -117,4 +133,4 @@ class InkHUD
} // namespace NicheGraphics::InkHUD
#endif
#endif
+15 -13
View File
@@ -12,7 +12,7 @@ using namespace NicheGraphics;
constexpr uint8_t MAX_MESSAGES_SAVED = 10;
constexpr uint32_t MAX_MESSAGE_SIZE = 250;
InkHUD::MessageStore::MessageStore(std::string label)
InkHUD::MessageStore::MessageStore(const std::string &label)
{
filename = "";
filename += "/NicheGraphics";
@@ -50,12 +50,13 @@ void InkHUD::MessageStore::saveToFlash()
// For each message
for (uint8_t i = 0; i < messages.size() && i < MAX_MESSAGES_SAVED; i++) {
Message &m = messages.at(i);
f.write((uint8_t *)&m.timestamp, sizeof(m.timestamp)); // Write timestamp. 4 bytes
f.write((uint8_t *)&m.sender, sizeof(m.sender)); // Write sender NodeId. 4 Bytes
f.write((uint8_t *)&m.channelIndex, sizeof(m.channelIndex)); // Write channel index. 1 Byte
f.write((uint8_t *)m.text.c_str(), min(MAX_MESSAGE_SIZE, m.text.size())); // Write message text. Variable length
f.write('\0'); // Append null term
LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", (uint32_t)i, min(MAX_MESSAGE_SIZE, m.text.size()), m.text.c_str());
f.write(reinterpret_cast<const uint8_t *>(&m.timestamp), sizeof(m.timestamp)); // Write timestamp. 4 bytes
f.write(reinterpret_cast<const uint8_t *>(&m.sender), sizeof(m.sender)); // Write sender NodeId. 4 Bytes
f.write(reinterpret_cast<const uint8_t *>(&m.channelIndex), sizeof(m.channelIndex)); // Write channel index. 1 Byte
f.write(reinterpret_cast<const uint8_t *>(m.text.c_str()), min(MAX_MESSAGE_SIZE, m.text.size())); // Write message text
f.write('\0'); // Append null term
LOG_DEBUG("Wrote message %u, length %u, text \"%s\"", static_cast<uint32_t>(i), min(MAX_MESSAGE_SIZE, m.text.size()),
m.text.c_str());
}
// Release firmware's SPI lock, because SafeFile::close needs it
@@ -111,17 +112,17 @@ void InkHUD::MessageStore::loadFromFlash()
// First byte: how many messages are in the flash store
uint8_t flashMessageCount = 0;
f.readBytes((char *)&flashMessageCount, 1);
LOG_DEBUG("Messages available: %u", (uint32_t)flashMessageCount);
f.readBytes(reinterpret_cast<char *>(&flashMessageCount), 1);
LOG_DEBUG("Messages available: %u", static_cast<uint32_t>(flashMessageCount));
// For each message
for (uint8_t i = 0; i < flashMessageCount && i < MAX_MESSAGES_SAVED; i++) {
Message m;
// Read meta data (fixed width)
f.readBytes((char *)&m.timestamp, sizeof(m.timestamp));
f.readBytes((char *)&m.sender, sizeof(m.sender));
f.readBytes((char *)&m.channelIndex, sizeof(m.channelIndex));
f.readBytes(reinterpret_cast<char *>(&m.timestamp), sizeof(m.timestamp));
f.readBytes(reinterpret_cast<char *>(&m.sender), sizeof(m.sender));
f.readBytes(reinterpret_cast<char *>(&m.channelIndex), sizeof(m.channelIndex));
// Read characters until we find a null term
char c;
@@ -136,7 +137,8 @@ void InkHUD::MessageStore::loadFromFlash()
// Store in RAM
messages.push_back(m);
LOG_DEBUG("#%u, timestamp=%u, sender(num)=%u, text=\"%s\"", (uint32_t)i, m.timestamp, m.sender, m.text.c_str());
LOG_DEBUG("#%u, timestamp=%u, sender(num)=%u, text=\"%s\"", static_cast<uint32_t>(i), m.timestamp, m.sender,
m.text.c_str());
}
f.close();
+1 -1
View File
@@ -31,7 +31,7 @@ class MessageStore
};
MessageStore() = delete;
explicit MessageStore(std::string label); // Label determines filename in flash
explicit MessageStore(const std::string &label); // Label determines filename in flash
void saveToFlash();
void loadFromFlash();
@@ -8,5 +8,5 @@ build_flags =
-D MESHTASTIC_EXCLUDE_INPUTBROKER ; Suppress default input handling
-D HAS_BUTTON=0 ; Suppress default ButtonThread
lib_deps =
# TODO renovate
https://github.com/ZinggJM/GFX_Root#2.0.0 ; Used by InkHUD as a "slimmer" version of AdafruitGFX
# renovate: datasource=github-tags depName=GFX_Root packageName=ZinggJM/GFX_Root
https://github.com/ZinggJM/GFX_Root/archive/3195764e352a0d2567c8d277ac408ca7293a99b0.zip ; Used by InkHUD as a "slimmer" version of AdafruitGFX
+103 -10
View File
@@ -56,15 +56,16 @@ void InkHUD::Renderer::setDisplayResilience(uint8_t fastPerFull, float stressMul
void InkHUD::Renderer::begin()
{
forceUpdate(Drivers::EInk::UpdateTypes::FULL, false);
forceUpdate(Drivers::EInk::UpdateTypes::FULL, true, false);
}
// Set a flag, which will be picked up by runOnce, ASAP.
// Quite likely, multiple applets will all want to respond to one event (Observable, etc)
// Each affected applet can independently call requestUpdate(), and all share the one opportunity to render, at next runOnce
void InkHUD::Renderer::requestUpdate()
void InkHUD::Renderer::requestUpdate(bool all)
{
requested = true;
renderAll |= all;
// We will run the thread as soon as we loop(),
// after all Applets have had a chance to observe whatever event set this off
@@ -79,10 +80,11 @@ void InkHUD::Renderer::requestUpdate()
// Sometimes, however, we will want to trigger a display update manually, in the absence of any sort of applet event
// Display health, for example.
// In these situations, we use forceUpdate
void InkHUD::Renderer::forceUpdate(Drivers::EInk::UpdateTypes type, bool async)
void InkHUD::Renderer::forceUpdate(Drivers::EInk::UpdateTypes type, bool all, bool async)
{
requested = true;
forced = true;
renderAll |= all;
displayHealth.forceUpdateType(type);
// Normally, we need to start the timer, in case the display is busy and we briefly defer the update
@@ -219,7 +221,8 @@ void InkHUD::Renderer::render(bool async)
Drivers::EInk::UpdateTypes updateType = decideUpdateType();
// Render the new image
clearBuffer();
if (renderAll)
clearBuffer();
renderUserApplets();
renderPlaceholders();
renderSystemApplets();
@@ -247,6 +250,7 @@ void InkHUD::Renderer::render(bool async)
// Tidy up, ready for a new request
requested = false;
forced = false;
renderAll = false;
}
// Manually fill the image buffer with WHITE
@@ -259,6 +263,76 @@ void InkHUD::Renderer::clearBuffer()
memset(imageBuffer, 0xFF, imageBufferHeight * imageBufferWidth);
}
// Manually clear the pixels below a tile
void InkHUD::Renderer::clearTile(Tile *t)
{
// Rotate the tile dimensions
int16_t left = 0;
int16_t top = 0;
uint16_t tileW = 0;
uint16_t tileH = 0;
switch (settings->rotation) {
case 0:
left = t->getLeft();
top = t->getTop();
tileW = t->getWidth();
tileH = t->getHeight();
break;
case 1:
left = driver->width - (t->getTop() + t->getHeight());
top = t->getLeft();
tileW = t->getHeight();
tileH = t->getWidth();
break;
case 2:
left = driver->width - (t->getLeft() + t->getWidth());
top = driver->height - (t->getTop() + t->getHeight());
tileW = t->getWidth();
tileH = t->getHeight();
break;
case 3:
left = t->getTop();
top = driver->height - (t->getLeft() + t->getWidth());
tileW = t->getHeight();
tileH = t->getWidth();
break;
}
// Calculate the bounds to clear
uint16_t xStart = (left < 0) ? 0 : left;
uint16_t yStart = (top < 0) ? 0 : top;
if (xStart >= driver->width || yStart >= driver->height || left + tileW < 0 || top + tileH < 0)
return; // the box is completely off the screen
uint16_t xEnd = left + tileW;
uint16_t yEnd = top + tileH;
if (xEnd > driver->width)
xEnd = driver->width;
if (yEnd > driver->height)
yEnd = driver->height;
// Clear the pixels
if (xStart == 0 && xEnd == driver->width) { // full width box is easier to clear
memset(imageBuffer + (yStart * imageBufferWidth), 0xFF, (yEnd - yStart) * imageBufferWidth);
} else {
const uint16_t byteStart = (xStart / 8) + 1;
const uint16_t byteEnd = xEnd / 8;
const uint8_t leadingByte = 0xFF >> (xStart - ((byteStart - 1) * 8));
const uint8_t trailingByte = (0xFF00 >> (xEnd - (byteEnd * 8))) & 0xFF;
for (uint16_t i = yStart * imageBufferWidth; i < yEnd * imageBufferWidth; i += imageBufferWidth) {
// Set the leading byte
imageBuffer[i + byteStart - 1] |= leadingByte;
// Set the continuous bytes
if (byteStart < byteEnd)
memset(imageBuffer + i + byteStart, 0xFF, byteEnd - byteStart);
// Set the trailing byte
if (byteEnd != imageBufferWidth)
imageBuffer[i + byteEnd] |= trailingByte;
}
}
}
void InkHUD::Renderer::checkLocks()
{
lockRendering = nullptr;
@@ -323,12 +397,12 @@ Drivers::EInk::UpdateTypes InkHUD::Renderer::decideUpdateType()
if (!forced) {
// User applets
for (Applet *ua : inkhud->userApplets) {
if (ua && ua->isForeground())
if (ua && ua->isForeground() && (ua->wantsToRender() || renderAll))
displayHealth.requestUpdateType(ua->wantsUpdateType());
}
// System Applets
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa && sa->isForeground())
if (sa && sa->isForeground() && (sa->wantsToRender() || sa->alwaysRender || renderAll))
displayHealth.requestUpdateType(sa->wantsUpdateType());
}
}
@@ -346,9 +420,16 @@ void InkHUD::Renderer::renderUserApplets()
// Render any user applets which are currently visible
for (Applet *ua : inkhud->userApplets) {
if (ua && ua->isActive() && ua->isForeground()) {
if (ua && ua->isActive() && ua->isForeground() && (ua->wantsToRender() || renderAll)) {
// Clear the tile unless the applet wants to draw over its previous render
// or everything is getting re-rendered anyways
if (ua->wantsFullRender() && !renderAll)
clearTile(ua->getTile());
uint32_t start = millis();
ua->render(); // Draw!
bool full = ua->wantsFullRender() || renderAll;
ua->render(full); // Draw!
uint32_t stop = millis();
LOG_DEBUG("%s took %dms to render", ua->name, stop - start);
}
@@ -370,6 +451,9 @@ void InkHUD::Renderer::renderSystemApplets()
if (!sa->isForeground())
continue;
if (!sa->wantsToRender() && !sa->alwaysRender && !renderAll)
continue;
// Skip if locked by another applet
if (lockRendering && lockRendering != sa)
continue;
@@ -381,8 +465,14 @@ void InkHUD::Renderer::renderSystemApplets()
assert(sa->getTile());
// Clear the tile unless the applet wants to draw over its previous render
// or everything is getting re-rendered anyways
if (sa->wantsFullRender() && !renderAll)
clearTile(sa->getTile());
// uint32_t start = millis();
sa->render(); // Draw!
bool full = sa->wantsFullRender() || renderAll;
sa->render(full); // Draw!
// uint32_t stop = millis();
// LOG_DEBUG("%s took %dms to render", sa->name, stop - start);
}
@@ -409,7 +499,10 @@ void InkHUD::Renderer::renderPlaceholders()
// uint32_t start = millis();
for (Tile *t : emptyTiles) {
t->assignApplet(placeholder);
placeholder->render();
// Clear the tile unless everything is getting re-rendered
if (!renderAll)
clearTile(t);
placeholder->render(true); // full render
t->assignApplet(nullptr);
}
// uint32_t stop = millis();
+6 -4
View File
@@ -37,8 +37,8 @@ class Renderer : protected concurrency::OSThread
// Call these to make the image change
void requestUpdate(); // Update display, if a foreground applet has info it wants to show
void forceUpdate(Drivers::EInk::UpdateTypes type = Drivers::EInk::UpdateTypes::UNSPECIFIED,
void requestUpdate(bool all = false); // Update display, if a foreground applet has info it wants to show
void forceUpdate(Drivers::EInk::UpdateTypes type = Drivers::EInk::UpdateTypes::UNSPECIFIED, bool all = false,
bool async = true); // Update display, regardless of whether any applets requested this
// Wait for an update to complete
@@ -53,7 +53,7 @@ class Renderer : protected concurrency::OSThread
uint16_t height();
private:
// Make attemps to render / update, once triggered by requestUpdate or forceUpdate
// Make attempts to render / update, once triggered by requestUpdate or forceUpdate
int32_t runOnce() override;
// Apply the display rotation to handled pixels
@@ -65,6 +65,7 @@ class Renderer : protected concurrency::OSThread
// Steps of the rendering process
void clearBuffer();
void clearTile(Tile *t);
void checkLocks();
bool shouldUpdate();
Drivers::EInk::UpdateTypes decideUpdateType();
@@ -85,6 +86,7 @@ class Renderer : protected concurrency::OSThread
bool requested = false;
bool forced = false;
bool renderAll = false;
// For convenience
InkHUD *inkhud = nullptr;
@@ -93,4 +95,4 @@ class Renderer : protected concurrency::OSThread
} // namespace NicheGraphics::InkHUD
#endif
#endif
+7 -4
View File
@@ -22,11 +22,14 @@ class SystemApplet : public Applet
public:
// System applets have the right to:
bool handleInput = false; // - respond to input from the user button
bool lockRendering = false; // - prevent other applets from being rendered during an update
bool lockRequests = false; // - prevent other applets from triggering display updates
bool handleInput = false; // - respond to input from the user button
bool handleFreeText = false; // - respond to free text input
bool lockRendering = false; // - prevent other applets from being rendered during an update
bool lockRequests = false; // - prevent other applets from triggering display updates
bool alwaysRender = false; // - render every time the screen is updated
virtual void onReboot() { onShutdown(); } // - handle reboot specially
virtual void onApplyingChanges() {}
// Other system applets may take precedence over our own system applet though
// The order an applet is passed to WindowManager::addSystemApplet determines this hierarchy (added earlier = higher rank)
@@ -40,4 +43,4 @@ class SystemApplet : public Applet
}; // namespace NicheGraphics::InkHUD
#endif
#endif
+14 -2
View File
@@ -18,7 +18,7 @@ static int32_t runtaskHighlight()
LOG_DEBUG("Dismissing Highlight");
InkHUD::Tile::highlightShown = false;
InkHUD::Tile::highlightTarget = nullptr;
InkHUD::InkHUD::getInstance()->forceUpdate(Drivers::EInk::UpdateTypes::FAST); // Re-render, clearing the highlighting
InkHUD::InkHUD::getInstance()->forceUpdate(Drivers::EInk::UpdateTypes::FAST, true); // Re-render, clearing the highlighting
return taskHighlight->disable();
}
static void inittaskHighlight()
@@ -190,6 +190,18 @@ void InkHUD::Tile::handleAppletPixel(int16_t x, int16_t y, Color c)
}
}
// Used in Renderer for clearing the tile
int16_t InkHUD::Tile::getLeft()
{
return left;
}
// Used in Renderer for clearing the tile
int16_t InkHUD::Tile::getTop()
{
return top;
}
// Called by Applet base class, when setting applet dimensions, immediately before render
uint16_t InkHUD::Tile::getWidth()
{
@@ -220,7 +232,7 @@ void InkHUD::Tile::requestHighlight()
{
Tile::highlightTarget = this;
Tile::highlightShown = false;
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FAST);
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FAST, true);
}
// Starts the timer which will automatically dismiss the highlighting, if the tile doesn't organically redraw first
+2
View File
@@ -29,6 +29,8 @@ class Tile
void setRegion(uint8_t layoutSize, uint8_t tileIndex); // Assign region automatically, based on layout
void setRegion(int16_t left, int16_t top, uint16_t width, uint16_t height); // Assign region manually
void handleAppletPixel(int16_t x, int16_t y, Color c); // Receive px output from assigned applet
int16_t getLeft();
int16_t getTop();
uint16_t getWidth();
uint16_t getHeight();
static uint16_t maxDisplayDimension(); // Largest possible width / height any tile may ever encounter
+70 -14
View File
@@ -4,6 +4,7 @@
#include "./Applets/System/AlignStick/AlignStickApplet.h"
#include "./Applets/System/BatteryIcon/BatteryIconApplet.h"
#include "./Applets/System/Keyboard/KeyboardApplet.h"
#include "./Applets/System/Logo/LogoApplet.h"
#include "./Applets/System/Menu/MenuApplet.h"
#include "./Applets/System/Notification/NotificationApplet.h"
@@ -142,12 +143,40 @@ void InkHUD::WindowManager::openMenu()
// Bring the AlignStick applet to the foreground
void InkHUD::WindowManager::openAlignStick()
{
if (settings->joystick.enabled) {
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
AlignStickApplet *alignStick = (AlignStickApplet *)inkhud->getSystemApplet("AlignStick");
alignStick->bringToForeground();
}
}
void InkHUD::WindowManager::openKeyboard()
{
if (!settings->joystick.enabled || inkhud->twoWayRocker)
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
if (keyboard) {
keyboard->bringToForeground();
keyboardOpen = true;
changeLayout();
}
}
void InkHUD::WindowManager::closeKeyboard()
{
if (!settings->joystick.enabled || inkhud->twoWayRocker)
return;
KeyboardApplet *keyboard = (KeyboardApplet *)inkhud->getSystemApplet("Keyboard");
if (keyboard) {
keyboard->sendToBackground();
keyboardOpen = false;
changeLayout();
}
}
// On the currently focussed tile: cycle to the next available user applet
// Applets available for this must be activated, and not already displayed on another tile
void InkHUD::WindowManager::nextApplet()
@@ -250,6 +279,12 @@ void InkHUD::WindowManager::prevApplet()
inkhud->forceUpdate(EInk::UpdateTypes::FAST); // bringToForeground already requested, but we're manually forcing FAST
}
// Returns active applet
NicheGraphics::InkHUD::Applet *InkHUD::WindowManager::getActiveApplet()
{
return userTiles.at(settings->userTiles.focused)->getAssignedApplet();
}
// Rotate the display image by 90 degrees
void InkHUD::WindowManager::rotate()
{
@@ -272,7 +307,6 @@ void InkHUD::WindowManager::toggleBatteryIcon()
batteryIcon->sendToBackground();
// Force-render
// - redraw all applets
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
}
@@ -311,9 +345,25 @@ void InkHUD::WindowManager::changeLayout()
menu->show(ft);
}
// Resize for the on-screen keyboard
if (keyboardOpen) {
// Send all user applets to the background
// User applets currently don't handle free text input
for (uint8_t i = 0; i < inkhud->userApplets.size(); i++)
inkhud->userApplets.at(i)->sendToBackground();
// Find the first system applet that can handle freetext and resize it
for (SystemApplet *sa : inkhud->systemApplets) {
if (sa->handleFreeText) {
const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight();
sa->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height() - keyboardHeight - 1);
break;
}
}
}
// Force-render
// - redraw all applets
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
inkhud->forceUpdate(EInk::UpdateTypes::FAST, true);
}
// Perform necessary reconfiguration when user activates or deactivates applets at run-time
@@ -347,7 +397,7 @@ void InkHUD::WindowManager::changeActivatedApplets()
// Force-render
// - redraw all applets
inkhud->forceUpdate(EInk::UpdateTypes::FAST);
inkhud->forceUpdate(EInk::UpdateTypes::FAST, true);
}
// Some applets may be permitted to bring themselves to foreground, to show new data
@@ -358,7 +408,7 @@ void InkHUD::WindowManager::autoshow()
{
// Don't perform autoshow if a system applet has exclusive use of the display right now
// Note: lockRequests prevents autoshow attempting to hide menuApplet
for (SystemApplet *sa : inkhud->systemApplets) {
for (const SystemApplet *sa : inkhud->systemApplets) {
if (sa->lockRendering || sa->lockRequests)
return;
}
@@ -433,8 +483,10 @@ void InkHUD::WindowManager::createSystemApplets()
addSystemApplet("Logo", new LogoApplet, new Tile);
addSystemApplet("Pairing", new PairingApplet, new Tile);
addSystemApplet("Tips", new TipsApplet, new Tile);
if (settings->joystick.enabled)
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
addSystemApplet("AlignStick", new AlignStickApplet, new Tile);
addSystemApplet("Keyboard", new KeyboardApplet, new Tile);
}
addSystemApplet("Menu", new MenuApplet, nullptr);
@@ -457,19 +509,23 @@ void InkHUD::WindowManager::placeSystemTiles()
inkhud->getSystemApplet("Logo")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
inkhud->getSystemApplet("Pairing")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
inkhud->getSystemApplet("Tips")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
if (settings->joystick.enabled)
if (settings->joystick.enabled && !inkhud->twoWayRocker) {
inkhud->getSystemApplet("AlignStick")->getTile()->setRegion(0, 0, inkhud->width(), inkhud->height());
const uint16_t keyboardHeight = KeyboardApplet::getKeyboardHeight();
inkhud->getSystemApplet("Keyboard")
->getTile()
->setRegion(0, inkhud->height() - keyboardHeight, inkhud->width(), keyboardHeight);
}
inkhud->getSystemApplet("Notification")->getTile()->setRegion(0, 0, inkhud->width(), 20);
const uint16_t batteryIconHeight = Applet::getHeaderHeight() - 2 - 2;
const uint16_t batteryIconWidth = batteryIconHeight * 1.8;
inkhud->getSystemApplet("BatteryIcon")
->getTile()
->setRegion(inkhud->width() - batteryIconWidth, // x
2, // y
batteryIconWidth, // width
batteryIconHeight); // height
->setRegion(inkhud->width() - batteryIconWidth - 1, // x
1, // y
batteryIconWidth + 1, // width
batteryIconHeight + 2); // height
// Note: the tiles of placeholder and menu applets are manipulated specially
// - menuApplet borrows user tiles
@@ -599,7 +655,7 @@ void InkHUD::WindowManager::refocusTile()
}
}
// Seach for any applets which believe they are foreground, but no longer have a valid tile
// Search for any applets which believe they are foreground, but no longer have a valid tile
// Tidies up after layout changes at runtime
void InkHUD::WindowManager::findOrphanApplets()
{
@@ -629,4 +685,4 @@ void InkHUD::WindowManager::findOrphanApplets()
}
}
#endif
#endif

Some files were not shown because too many files have changed in this diff Show More