Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c06897ee | ||
|
|
309d51a3e8 | ||
|
|
93f87c57b9 | ||
|
|
94ef2ae451 | ||
|
|
abef0d85a2 | ||
|
|
6b3f975ba5 |
@@ -14,6 +14,7 @@
|
||||
* For more information, see: https://meshtastic.org/
|
||||
*/
|
||||
#include "power.h"
|
||||
#include "BluetoothCommon.h"
|
||||
#include "MessageStore.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PowerFSM.h"
|
||||
@@ -962,6 +963,10 @@ void Power::readPowerStatus()
|
||||
lastLogTime = millis();
|
||||
}
|
||||
newStatus.notifyObservers(&powerStatus2);
|
||||
|
||||
// Mirror battery level to the BLE Battery Service (0x2A19); the platform layer clamps and dedupes.
|
||||
if (hasBattery == OptTrue)
|
||||
updateBatteryLevel(powerStatus2.getBatteryChargePercent());
|
||||
#ifdef DEBUG_HEAP
|
||||
if (lastheap != memGet.getFreeHeap()) {
|
||||
// Use stack-allocated buffer to avoid heap allocations in monitoring code
|
||||
|
||||
+64
-109
@@ -102,8 +102,8 @@ namespace graphics
|
||||
// if defined a pixel will blink to show redraws
|
||||
// #define SHOW_REDRAWS
|
||||
#define ASCII_BELL '\x07'
|
||||
// A text message frame + debug frame + all the node infos
|
||||
FrameCallback *normalFrames;
|
||||
// Base frames plus active module/favorite frames; grows only when the actual frameset needs it.
|
||||
static std::vector<FrameCallback> normalFrames;
|
||||
static uint32_t targetFramerate = IDLE_FRAMERATE;
|
||||
#if GRAPHICS_TFT_COLORING_ENABLED
|
||||
static inline void prepareFrameColorRegions()
|
||||
@@ -136,6 +136,7 @@ uint32_t dopThresholds[5] = {2000, 1000, 500, 200, 100};
|
||||
// At some point, we're going to ask all of the modules if they would like to display a screen frame
|
||||
// we'll need to hold onto pointers for the modules that can draw a frame.
|
||||
std::vector<MeshModule *> moduleFrames;
|
||||
static uint8_t moduleFrameStart = 0;
|
||||
|
||||
#if HAS_GPS
|
||||
// GeoCoord object for the screen
|
||||
@@ -333,8 +334,14 @@ static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int
|
||||
// otherwise, just display the module frame that's aligned with the current frame
|
||||
module_frame = state->currentFrame;
|
||||
}
|
||||
MeshModule &pi = *moduleFrames.at(module_frame);
|
||||
pi.drawFrame(display, state, x, y);
|
||||
if (module_frame < moduleFrameStart)
|
||||
return;
|
||||
|
||||
const uint8_t moduleIndex = module_frame - moduleFrameStart;
|
||||
if (moduleIndex >= moduleFrames.size() || moduleFrames[moduleIndex] == nullptr)
|
||||
return;
|
||||
|
||||
moduleFrames[moduleIndex]->drawFrame(display, state, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,9 +409,11 @@ SPIClass SPI1(HSPI);
|
||||
#endif
|
||||
|
||||
Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_OledType screenType, OLEDDISPLAY_GEOMETRY geometry)
|
||||
: concurrency::OSThread("Screen"), address_found(address), model(screenType), geometry(geometry), cmdQueue(32)
|
||||
: concurrency::OSThread("Screen"), address_found(address), model(screenType), geometry(geometry), cmdQueue(16)
|
||||
{
|
||||
graphics::normalFrames = new FrameCallback[MAX_NUM_NODES + NUM_EXTRA_FRAMES];
|
||||
normalFrames.reserve(24);
|
||||
indicatorIcons.reserve(24);
|
||||
moduleFrames.reserve(8);
|
||||
|
||||
#if defined(USE_SH1106) || defined(USE_SH1107) || defined(USE_SH1107_128_64)
|
||||
dispdev = new SH1106Wire(address.address, -1, -1, geometry,
|
||||
@@ -488,7 +497,6 @@ Screen::Screen(ScanI2C::DeviceAddress address, meshtastic_Config_DisplayConfig_O
|
||||
|
||||
Screen::~Screen()
|
||||
{
|
||||
delete[] graphics::normalFrames;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1142,183 +1150,130 @@ void Screen::setFrames(FrameFocus focus)
|
||||
LOG_DEBUG("Show standard frames");
|
||||
showingNormalScreen = true;
|
||||
|
||||
normalFrames.clear();
|
||||
indicatorIcons.clear();
|
||||
|
||||
size_t numframes = 0;
|
||||
auto appendFrame = [this](FrameCallback frame, const uint8_t *icon) -> uint8_t {
|
||||
normalFrames.emplace_back(frame);
|
||||
indicatorIcons.push_back(icon);
|
||||
return normalFrames.size() - 1;
|
||||
};
|
||||
|
||||
// If we have a critical fault, show it first
|
||||
fsi.positions.fault = numframes;
|
||||
fsi.positions.fault = normalFrames.size();
|
||||
if (error_code) {
|
||||
normalFrames[numframes++] = NotificationRenderer::drawCriticalFaultFrame;
|
||||
indicatorIcons.push_back(icon_error);
|
||||
fsi.positions.fault = appendFrame(NotificationRenderer::drawCriticalFaultFrame, icon_error);
|
||||
focus = FOCUS_FAULT; // Change our "focus" parameter, to ensure we show the fault frame
|
||||
}
|
||||
|
||||
#if defined(DISPLAY_CLOCK_FRAME)
|
||||
if (!hiddenFrames.clock) {
|
||||
fsi.positions.clock = numframes;
|
||||
#if defined(OLED_TINY)
|
||||
normalFrames[numframes++] = graphics::ClockRenderer::drawAnalogClockFrame;
|
||||
fsi.positions.clock = appendFrame(graphics::ClockRenderer::drawAnalogClockFrame, digital_icon_clock);
|
||||
#else
|
||||
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
fsi.positions.clock = appendFrame(uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame,
|
||||
digital_icon_clock);
|
||||
#endif
|
||||
indicatorIcons.push_back(digital_icon_clock);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!hiddenFrames.home) {
|
||||
fsi.positions.home = numframes;
|
||||
normalFrames[numframes++] = graphics::UIRenderer::drawDeviceFocused;
|
||||
indicatorIcons.push_back(icon_home);
|
||||
fsi.positions.home = appendFrame(graphics::UIRenderer::drawDeviceFocused, icon_home);
|
||||
}
|
||||
|
||||
fsi.positions.textMessage = numframes;
|
||||
normalFrames[numframes++] = graphics::MessageRenderer::drawTextMessageFrame;
|
||||
indicatorIcons.push_back(icon_mail);
|
||||
fsi.positions.textMessage = appendFrame(graphics::MessageRenderer::drawTextMessageFrame, icon_mail);
|
||||
|
||||
#ifndef USE_EINK
|
||||
if (!hiddenFrames.nodelist_nodes) {
|
||||
fsi.positions.nodelist_nodes = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Nodes;
|
||||
indicatorIcons.push_back(icon_nodes);
|
||||
fsi.positions.nodelist_nodes = appendFrame(graphics::NodeListRenderer::drawDynamicListScreen_Nodes, icon_nodes);
|
||||
}
|
||||
if (!hiddenFrames.nodelist_location) {
|
||||
fsi.positions.nodelist_location = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDynamicListScreen_Location;
|
||||
indicatorIcons.push_back(icon_list);
|
||||
fsi.positions.nodelist_location = appendFrame(graphics::NodeListRenderer::drawDynamicListScreen_Location, icon_list);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Show detailed node views only on E-Ink builds
|
||||
#ifdef USE_EINK
|
||||
if (!hiddenFrames.nodelist_lastheard) {
|
||||
fsi.positions.nodelist_lastheard = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawLastHeardScreen;
|
||||
indicatorIcons.push_back(icon_nodes);
|
||||
fsi.positions.nodelist_lastheard = appendFrame(graphics::NodeListRenderer::drawLastHeardScreen, icon_nodes);
|
||||
}
|
||||
if (!hiddenFrames.nodelist_hopsignal) {
|
||||
fsi.positions.nodelist_hopsignal = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawHopSignalScreen;
|
||||
indicatorIcons.push_back(icon_signal);
|
||||
fsi.positions.nodelist_hopsignal = appendFrame(graphics::NodeListRenderer::drawHopSignalScreen, icon_signal);
|
||||
}
|
||||
if (!hiddenFrames.nodelist_distance) {
|
||||
fsi.positions.nodelist_distance = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawDistanceScreen;
|
||||
indicatorIcons.push_back(icon_distance);
|
||||
fsi.positions.nodelist_distance = appendFrame(graphics::NodeListRenderer::drawDistanceScreen, icon_distance);
|
||||
}
|
||||
#endif
|
||||
#if HAS_GPS
|
||||
#ifdef USE_EINK
|
||||
if (!hiddenFrames.nodelist_bearings) {
|
||||
fsi.positions.nodelist_bearings = numframes;
|
||||
normalFrames[numframes++] = graphics::NodeListRenderer::drawNodeListWithCompasses;
|
||||
indicatorIcons.push_back(icon_list);
|
||||
fsi.positions.nodelist_bearings = appendFrame(graphics::NodeListRenderer::drawNodeListWithCompasses, icon_list);
|
||||
}
|
||||
#endif
|
||||
if (!hiddenFrames.gps) {
|
||||
fsi.positions.gps = numframes;
|
||||
normalFrames[numframes++] = graphics::UIRenderer::drawCompassAndLocationScreen;
|
||||
indicatorIcons.push_back(icon_compass);
|
||||
fsi.positions.gps = appendFrame(graphics::UIRenderer::drawCompassAndLocationScreen, icon_compass);
|
||||
}
|
||||
#endif
|
||||
if (RadioLibInterface::instance && !hiddenFrames.lora) {
|
||||
fsi.positions.lora = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawLoRaFocused;
|
||||
indicatorIcons.push_back(icon_radio);
|
||||
fsi.positions.lora = appendFrame(graphics::DebugRenderer::drawLoRaFocused, icon_radio);
|
||||
}
|
||||
if (!hiddenFrames.system) {
|
||||
fsi.positions.system = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawSystemScreen;
|
||||
indicatorIcons.push_back(icon_system);
|
||||
fsi.positions.system = appendFrame(graphics::DebugRenderer::drawSystemScreen, icon_system);
|
||||
}
|
||||
#if !defined(DISPLAY_CLOCK_FRAME)
|
||||
if (!hiddenFrames.clock) {
|
||||
fsi.positions.clock = numframes;
|
||||
normalFrames[numframes++] = uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame;
|
||||
indicatorIcons.push_back(digital_icon_clock);
|
||||
fsi.positions.clock = appendFrame(uiconfig.is_clockface_analog ? graphics::ClockRenderer::drawAnalogClockFrame
|
||||
: graphics::ClockRenderer::drawDigitalClockFrame,
|
||||
digital_icon_clock);
|
||||
}
|
||||
#endif
|
||||
if (!hiddenFrames.chirpy) {
|
||||
fsi.positions.chirpy = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawChirpy;
|
||||
indicatorIcons.push_back(chirpy_small);
|
||||
fsi.positions.chirpy = appendFrame(graphics::DebugRenderer::drawChirpy, chirpy_small);
|
||||
}
|
||||
|
||||
#if HAS_WIFI && !defined(ARCH_PORTDUINO)
|
||||
if (!hiddenFrames.wifi && isWifiAvailable()) {
|
||||
fsi.positions.wifi = numframes;
|
||||
normalFrames[numframes++] = graphics::DebugRenderer::drawDebugInfoWiFiTrampoline;
|
||||
indicatorIcons.push_back(icon_wifi);
|
||||
fsi.positions.wifi = appendFrame(graphics::DebugRenderer::drawDebugInfoWiFiTrampoline, icon_wifi);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Beware of what changes you make in this code!
|
||||
// We pass numframes into GetMeshModulesWithUIFrames() which is highly important!
|
||||
// Inside of that callback, goes over to MeshModule.cpp and we run
|
||||
// modulesWithUIFrames.resize(startIndex, nullptr), to insert nullptr
|
||||
// entries until we're ready to start building the matching entries.
|
||||
// We are doing our best to keep the normalFrames vector
|
||||
// and the moduleFrames vector in lock step.
|
||||
moduleFrames = MeshModule::GetMeshModulesWithUIFrames(numframes);
|
||||
moduleFrameStart = normalFrames.size();
|
||||
moduleFrames = MeshModule::GetMeshModulesWithUIFrames();
|
||||
LOG_DEBUG("Show %d module frames", moduleFrames.size());
|
||||
|
||||
for (auto i = moduleFrames.begin(); i != moduleFrames.end(); ++i) {
|
||||
// Draw the module frame, using the hack described above
|
||||
if (*i != nullptr) {
|
||||
normalFrames[numframes] = drawModuleFrame;
|
||||
|
||||
// Check if the module being drawn has requested focus
|
||||
// We will honor this request later, if setFrames was triggered by a UIFrameEvent
|
||||
MeshModule *m = *i;
|
||||
if (m && m->isRequestingFocus())
|
||||
fsi.positions.focusedModule = numframes;
|
||||
if (m && m == waypointModule)
|
||||
fsi.positions.waypoint = numframes;
|
||||
|
||||
indicatorIcons.push_back(icon_module);
|
||||
numframes++;
|
||||
}
|
||||
for (MeshModule *m : moduleFrames) {
|
||||
const uint8_t frameIndex = appendFrame(drawModuleFrame, icon_module);
|
||||
if (m && m->isRequestingFocus())
|
||||
fsi.positions.focusedModule = frameIndex;
|
||||
if (m && m == waypointModule)
|
||||
fsi.positions.waypoint = frameIndex;
|
||||
}
|
||||
|
||||
LOG_DEBUG("Added modules. numframes: %d", numframes);
|
||||
|
||||
// We don't show the node info of our node (if we have it yet - we should)
|
||||
size_t numMeshNodes = nodeDB->getNumMeshNodes();
|
||||
if (numMeshNodes > 0)
|
||||
numMeshNodes--;
|
||||
LOG_DEBUG("Added modules. numframes: %d", normalFrames.size());
|
||||
|
||||
if (!hiddenFrames.show_favorites) {
|
||||
// Temporary array to hold favorite node frames
|
||||
std::vector<FrameCallback> favoriteFrames;
|
||||
|
||||
uint8_t firstFavorite = 255;
|
||||
uint8_t lastFavorite = 255;
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
const meshtastic_NodeInfoLite *n = nodeDB->getMeshNodeByIndex(i);
|
||||
if (n && n->num != nodeDB->getNodeNum() && nodeInfoLiteIsFavorite(n)) {
|
||||
favoriteFrames.push_back(graphics::UIRenderer::drawFavoriteNode);
|
||||
const uint8_t frameIndex = appendFrame(graphics::UIRenderer::drawFavoriteNode, icon_node);
|
||||
if (firstFavorite == 255)
|
||||
firstFavorite = frameIndex;
|
||||
lastFavorite = frameIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert favorite frames *after* collecting them all
|
||||
if (!favoriteFrames.empty()) {
|
||||
fsi.positions.firstFavorite = numframes;
|
||||
for (const auto &f : favoriteFrames) {
|
||||
normalFrames[numframes++] = f;
|
||||
indicatorIcons.push_back(icon_node);
|
||||
}
|
||||
fsi.positions.lastFavorite = numframes - 1;
|
||||
} else {
|
||||
fsi.positions.firstFavorite = 255;
|
||||
fsi.positions.lastFavorite = 255;
|
||||
}
|
||||
fsi.positions.firstFavorite = firstFavorite;
|
||||
fsi.positions.lastFavorite = lastFavorite;
|
||||
}
|
||||
|
||||
fsi.frameCount = numframes; // Total framecount is used to apply FOCUS_PRESERVE
|
||||
this->frameCount = numframes; // Save frame count for use in custom overlay
|
||||
LOG_DEBUG("Finished build frames. numframes: %d", numframes);
|
||||
fsi.frameCount = normalFrames.size(); // Total framecount is used to apply FOCUS_PRESERVE
|
||||
this->frameCount = normalFrames.size(); // Save frame count for use in custom overlay
|
||||
LOG_DEBUG("Finished build frames. numframes: %d", normalFrames.size());
|
||||
|
||||
ui->setFrames(normalFrames, numframes);
|
||||
ui->setFrames(normalFrames.data(), fsi.frameCount);
|
||||
ui->disableAllIndicators();
|
||||
|
||||
// Add overlays: frame icons and alert banner)
|
||||
|
||||
@@ -154,8 +154,8 @@ static std::vector<uint32_t> seenPeers;
|
||||
// Public helper so menus / store can clear stale registries
|
||||
void clearThreadRegistries()
|
||||
{
|
||||
seenChannels.clear();
|
||||
seenPeers.clear();
|
||||
std::vector<int>().swap(seenChannels);
|
||||
std::vector<uint32_t>().swap(seenPeers);
|
||||
}
|
||||
|
||||
// Setter so other code can switch threads
|
||||
@@ -387,6 +387,58 @@ static void drawMessageScrollbar(OLEDDisplay *display, int visibleHeight, int to
|
||||
}
|
||||
}
|
||||
|
||||
static void appendWrappedLines(OLEDDisplay *display, const char *messageBuf, int textWidth, std::vector<std::string> &allLines,
|
||||
size_t maxTotalLines, size_t maxWrappedLines)
|
||||
{
|
||||
std::string line;
|
||||
std::string word;
|
||||
size_t wrappedCount = 0;
|
||||
|
||||
auto appendLine = [&](std::string &&newLine) -> bool {
|
||||
if (newLine.empty())
|
||||
return true;
|
||||
if (allLines.size() >= maxTotalLines || wrappedCount >= maxWrappedLines)
|
||||
return false;
|
||||
allLines.emplace_back(std::move(newLine));
|
||||
++wrappedCount;
|
||||
return true;
|
||||
};
|
||||
|
||||
for (int i = 0; messageBuf[i]; ++i) {
|
||||
char ch = messageBuf[i];
|
||||
if ((unsigned char)messageBuf[i] == 0xE2 && (unsigned char)messageBuf[i + 1] == 0x80 &&
|
||||
(unsigned char)messageBuf[i + 2] == 0x99) {
|
||||
ch = '\''; // plain apostrophe
|
||||
i += 2; // skip over the extra UTF-8 bytes
|
||||
}
|
||||
if (ch == '\n') {
|
||||
if (!word.empty())
|
||||
line += word;
|
||||
if (!appendLine(std::move(line)))
|
||||
return;
|
||||
line.clear();
|
||||
word.clear();
|
||||
} else if (ch == ' ') {
|
||||
line += word + ' ';
|
||||
word.clear();
|
||||
} else {
|
||||
word += ch;
|
||||
std::string test = line + word;
|
||||
uint16_t strWidth = graphics::UIRenderer::measureStringWithEmotes(display, test.c_str());
|
||||
if (strWidth > textWidth) {
|
||||
if (!line.empty() && !appendLine(std::move(line)))
|
||||
return;
|
||||
line = word;
|
||||
word.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!word.empty())
|
||||
line += word;
|
||||
appendLine(std::move(line));
|
||||
}
|
||||
|
||||
void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
// Ensure any boot-relative timestamps are upgraded if RTC is valid
|
||||
@@ -646,19 +698,15 @@ void drawTextMessageFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16
|
||||
const char *msgText = MessageStore::getText(m);
|
||||
|
||||
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) {
|
||||
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));
|
||||
const size_t previousLineCount = allLines.size();
|
||||
appendWrappedLines(display, msgText, wrapWidth, allLines, MAX_CACHED_LINES, MAX_WRAPPED_LINES_PER_MSG);
|
||||
for (size_t i = previousLineCount; i < allLines.size(); ++i) {
|
||||
isMine.push_back(mine);
|
||||
isHeader.push_back(false);
|
||||
ackForLine.push_back(AckStatus::NONE);
|
||||
++wrappedCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,20 +1056,23 @@ std::vector<int> calculateLineHeights(const std::vector<std::string> &lines, con
|
||||
|
||||
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));
|
||||
}
|
||||
auto currentMetrics = graphics::EmoteRenderer::LineMetrics{};
|
||||
auto nextMetrics = lines.empty()
|
||||
? graphics::EmoteRenderer::LineMetrics{}
|
||||
: graphics::EmoteRenderer::analyzeLine(nullptr, lines[0], FONT_HEIGHT_SMALL, emotes, numEmotes);
|
||||
|
||||
for (size_t idx = 0; idx < lines.size(); ++idx) {
|
||||
currentMetrics = nextMetrics;
|
||||
nextMetrics = (idx + 1 < lines.size())
|
||||
? graphics::EmoteRenderer::analyzeLine(nullptr, lines[idx + 1], FONT_HEIGHT_SMALL, emotes, numEmotes)
|
||||
: graphics::EmoteRenderer::LineMetrics{};
|
||||
|
||||
const int baseHeight = FONT_HEIGHT_SMALL;
|
||||
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;
|
||||
const int tallestEmote = currentMetrics.tallestHeight;
|
||||
const bool hasEmote = currentMetrics.hasEmote;
|
||||
const bool nextHasEmote = (idx + 1 < lines.size()) && nextMetrics.hasEmote;
|
||||
|
||||
if (isHeaderVec[idx]) {
|
||||
// Header line spacing
|
||||
|
||||
@@ -244,13 +244,10 @@ void setReplyTo(meshtastic_MeshPacket *p, const meshtastic_MeshPacket &to)
|
||||
p->decoded.request_id = to.id;
|
||||
}
|
||||
|
||||
std::vector<MeshModule *> MeshModule::GetMeshModulesWithUIFrames(int startIndex)
|
||||
std::vector<MeshModule *> MeshModule::GetMeshModulesWithUIFrames()
|
||||
{
|
||||
std::vector<MeshModule *> modulesWithUIFrames;
|
||||
|
||||
// Fill with nullptr up to startIndex
|
||||
modulesWithUIFrames.resize(startIndex, nullptr);
|
||||
|
||||
if (modules) {
|
||||
for (auto i = modules->begin(); i != modules->end(); ++i) {
|
||||
auto &pi = **i;
|
||||
@@ -311,4 +308,4 @@ bool MeshModule::isRequestingFocus()
|
||||
} else
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -76,7 +76,7 @@ class MeshModule
|
||||
*/
|
||||
static void callModules(meshtastic_MeshPacket &mp, RxSource src = RX_SRC_RADIO);
|
||||
|
||||
static std::vector<MeshModule *> GetMeshModulesWithUIFrames(int startIndex);
|
||||
static std::vector<MeshModule *> GetMeshModulesWithUIFrames();
|
||||
static void observeUIEvents(Observer<const UIFrameEvent *> *observer);
|
||||
static AdminMessageHandleResult handleAdminMessageForAllModules(const meshtastic_MeshPacket &mp,
|
||||
meshtastic_AdminMessage *request,
|
||||
|
||||
@@ -44,6 +44,8 @@ extern MessageStore messageStore;
|
||||
|
||||
#include "graphics/ScreenFonts.h"
|
||||
#include <Throttle.h>
|
||||
#include <cctype>
|
||||
#include <new>
|
||||
|
||||
// Remove Canned message screen if no action is taken for some milliseconds
|
||||
#define INACTIVATE_AFTER_MS 20000
|
||||
@@ -144,6 +146,36 @@ bool hasKeyForNode(const meshtastic_NodeInfoLite *node)
|
||||
{
|
||||
return nodeInfoLiteHasUser(node) && node->public_key.size > 0;
|
||||
}
|
||||
|
||||
static bool containsIgnoreCase(const char *text, const char *query)
|
||||
{
|
||||
if (!query || !*query)
|
||||
return true;
|
||||
if (!text || !*text)
|
||||
return false;
|
||||
|
||||
const size_t queryLen = strlen(query);
|
||||
for (const char *p = text; *p; ++p) {
|
||||
size_t i = 0;
|
||||
while (i < queryLen && p[i] &&
|
||||
std::tolower(static_cast<unsigned char>(p[i])) == std::tolower(static_cast<unsigned char>(query[i]))) {
|
||||
++i;
|
||||
}
|
||||
if (i == queryLen)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool activeChannelNameSeen(const std::vector<uint8_t> &activeChannelIndices, const char *name)
|
||||
{
|
||||
for (uint8_t channelIndex : activeChannelIndices) {
|
||||
const char *existingName = channels.getName(channelIndex);
|
||||
if (existingName && strcmp(existingName, name) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* @brief Items in array this->messages will be set to be pointing on the right
|
||||
* starting points of the string this->messageStore
|
||||
@@ -155,10 +187,9 @@ int CannedMessageModule::splitConfiguredMessages()
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
String canned_messages = cannedMessageModuleConfig.messages;
|
||||
|
||||
// Copy all message parts into the buffer
|
||||
strncpy(this->messageBuffer, canned_messages.c_str(), sizeof(this->messageBuffer));
|
||||
strncpy(this->messageBuffer, cannedMessageModuleConfig.messages, sizeof(this->messageBuffer) - 1);
|
||||
this->messageBuffer[sizeof(this->messageBuffer) - 1] = '\0';
|
||||
|
||||
// Temporary array to allow for insertion
|
||||
const char *tempMessages[CANNED_MESSAGE_MODULE_MESSAGE_MAX_COUNT + 3] = {0};
|
||||
@@ -222,7 +253,7 @@ void CannedMessageModule::resetSearch()
|
||||
{
|
||||
int previousDestIndex = destIndex;
|
||||
|
||||
searchQuery = "";
|
||||
searchQuery[0] = '\0';
|
||||
updateDestinationSelectionList();
|
||||
|
||||
// Adjust scrollIndex so previousDestIndex is still visible
|
||||
@@ -238,26 +269,21 @@ void CannedMessageModule::resetSearch()
|
||||
}
|
||||
void CannedMessageModule::updateDestinationSelectionList()
|
||||
{
|
||||
static size_t lastNumMeshNodes = 0;
|
||||
static String lastSearchQuery = "";
|
||||
|
||||
size_t numMeshNodes = nodeDB->getNumMeshNodes();
|
||||
bool nodesChanged = (numMeshNodes != lastNumMeshNodes);
|
||||
lastNumMeshNodes = numMeshNodes;
|
||||
bool nodesChanged = (numMeshNodes != cachedDestinationNodeCount);
|
||||
cachedDestinationNodeCount = numMeshNodes;
|
||||
|
||||
// Early exit if nothing changed
|
||||
if (searchQuery == lastSearchQuery && !nodesChanged)
|
||||
if (destinationSelectionCacheValid && strcmp(searchQuery, cachedDestinationSearchQuery) == 0 && !nodesChanged)
|
||||
return;
|
||||
lastSearchQuery = searchQuery;
|
||||
strncpy(cachedDestinationSearchQuery, searchQuery, sizeof(cachedDestinationSearchQuery) - 1);
|
||||
cachedDestinationSearchQuery[sizeof(cachedDestinationSearchQuery) - 1] = '\0';
|
||||
needsUpdate = false;
|
||||
|
||||
this->filteredNodes.clear();
|
||||
this->activeChannelIndices.clear();
|
||||
|
||||
NodeNum myNodeNum = nodeDB->getNodeNum();
|
||||
String lowerSearchQuery = searchQuery;
|
||||
lowerSearchQuery.toLowerCase();
|
||||
|
||||
// Preallocate space to reduce reallocation
|
||||
this->filteredNodes.reserve(numMeshNodes);
|
||||
|
||||
@@ -266,38 +292,27 @@ void CannedMessageModule::updateDestinationSelectionList()
|
||||
if (!node || node->num == myNodeNum || !nodeInfoLiteHasUser(node) || node->public_key.size != 32)
|
||||
continue;
|
||||
|
||||
const String &nodeName = node->long_name;
|
||||
const char *nodeName = node->long_name;
|
||||
const char *shortName = node->short_name;
|
||||
|
||||
if (searchQuery.length() == 0) {
|
||||
if (searchQuery[0] == '\0') {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
} else if (containsIgnoreCase(nodeName, searchQuery) || containsIgnoreCase(shortName, searchQuery)) {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
} else {
|
||||
// Avoid unnecessary lowercase conversion if already matched
|
||||
String lowerNodeName = nodeName;
|
||||
lowerNodeName.toLowerCase();
|
||||
|
||||
if (lowerNodeName.indexOf(lowerSearchQuery) != -1) {
|
||||
this->filteredNodes.push_back({node, sinceLastSeen(node)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meshtastic_MeshPacket *p = allocDataPacket();
|
||||
p->pki_encrypted = true;
|
||||
p->channel = 0;
|
||||
|
||||
// Populate active channels
|
||||
std::vector<String> seenChannels;
|
||||
seenChannels.reserve(channels.getNumChannels());
|
||||
for (uint8_t i = 0; i < channels.getNumChannels(); ++i) {
|
||||
String name = channels.getName(i);
|
||||
if (name.length() > 0 && std::find(seenChannels.begin(), seenChannels.end(), name) == seenChannels.end()) {
|
||||
const char *name = channels.getName(i);
|
||||
if (name && name[0] && !activeChannelNameSeen(activeChannelIndices, name)) {
|
||||
this->activeChannelIndices.push_back(i);
|
||||
seenChannels.push_back(name);
|
||||
}
|
||||
}
|
||||
|
||||
scrollIndex = 0; // Show first result at the top
|
||||
destIndex = 0; // Highlight the first entry
|
||||
destinationSelectionCacheValid = true;
|
||||
if (nodesChanged && runState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
|
||||
LOG_INFO("Nodes changed, forcing UI refresh.");
|
||||
screen->forceDisplay();
|
||||
@@ -480,7 +495,11 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event)
|
||||
|
||||
void CannedMessageModule::updateState(cannedMessageModuleRunState newState, bool shouldRequestFocus)
|
||||
{
|
||||
cannedMessageModuleRunState oldState = runState;
|
||||
runState = newState;
|
||||
if (oldState == CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION && newState != CANNED_MESSAGE_RUN_STATE_DESTINATION_SELECTION) {
|
||||
releaseDestinationSelectionCache();
|
||||
}
|
||||
if (runState == CANNED_MESSAGE_RUN_STATE_FREETEXT) {
|
||||
inputBroker->menuMode =
|
||||
false; // Allow any key input to be sent to the message composer instead of being interpreted as menu navigation
|
||||
@@ -492,6 +511,26 @@ void CannedMessageModule::updateState(cannedMessageModuleRunState newState, bool
|
||||
}
|
||||
}
|
||||
|
||||
void CannedMessageModule::releaseDestinationSelectionCache()
|
||||
{
|
||||
searchQuery[0] = '\0';
|
||||
cachedDestinationSearchQuery[0] = '\0';
|
||||
cachedDestinationNodeCount = 0;
|
||||
destinationSelectionCacheValid = false;
|
||||
destIndex = 0;
|
||||
scrollIndex = 0;
|
||||
|
||||
std::vector<uint8_t>().swap(activeChannelIndices);
|
||||
std::vector<NodeEntry>().swap(filteredNodes);
|
||||
}
|
||||
|
||||
void CannedMessageModule::releaseFreetext()
|
||||
{
|
||||
freetext.~String();
|
||||
new (&freetext) String();
|
||||
cursor = 0;
|
||||
}
|
||||
|
||||
bool CannedMessageModule::isUpEvent(const InputEvent *event)
|
||||
{
|
||||
return event->inputEvent == INPUT_BROKER_UP ||
|
||||
@@ -547,11 +586,15 @@ int CannedMessageModule::handleDestinationSelectionInput(const InputEvent *event
|
||||
|
||||
if (event->kbchar >= 32 && event->kbchar <= 126 && !isUp && !isDown && event->inputEvent != INPUT_BROKER_LEFT &&
|
||||
event->inputEvent != INPUT_BROKER_RIGHT && event->inputEvent != INPUT_BROKER_SELECT) {
|
||||
this->searchQuery += (char)event->kbchar;
|
||||
needsUpdate = true;
|
||||
if ((millis() - lastFilterUpdate) > filterDebounceMs) {
|
||||
runOnce(); // update filter immediately
|
||||
lastFilterUpdate = millis();
|
||||
size_t queryLen = strlen(searchQuery);
|
||||
if (queryLen + 1 < sizeof(searchQuery)) {
|
||||
searchQuery[queryLen] = static_cast<char>(event->kbchar);
|
||||
searchQuery[queryLen + 1] = '\0';
|
||||
needsUpdate = true;
|
||||
if ((millis() - lastFilterUpdate) > filterDebounceMs) {
|
||||
runOnce(); // update filter immediately
|
||||
lastFilterUpdate = millis();
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -565,12 +608,13 @@ int CannedMessageModule::handleDestinationSelectionInput(const InputEvent *event
|
||||
|
||||
// Handle backspace
|
||||
if (event->inputEvent == INPUT_BROKER_BACK) {
|
||||
if (searchQuery.length() > 0) {
|
||||
searchQuery.remove(searchQuery.length() - 1);
|
||||
size_t queryLen = strlen(searchQuery);
|
||||
if (queryLen > 0) {
|
||||
searchQuery[queryLen - 1] = '\0';
|
||||
needsUpdate = true;
|
||||
runOnce();
|
||||
}
|
||||
if (searchQuery.length() == 0) {
|
||||
if (searchQuery[0] == '\0') {
|
||||
resetSearch();
|
||||
needsUpdate = false;
|
||||
}
|
||||
@@ -641,7 +685,7 @@ int CannedMessageModule::handleDestinationSelectionInput(const InputEvent *event
|
||||
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG) {
|
||||
updateState(returnToCannedList ? CANNED_MESSAGE_RUN_STATE_ACTIVE : CANNED_MESSAGE_RUN_STATE_FREETEXT, true);
|
||||
returnToCannedList = false;
|
||||
searchQuery = "";
|
||||
searchQuery[0] = '\0';
|
||||
|
||||
// UIFrameEvent e;
|
||||
// e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
@@ -671,8 +715,7 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo
|
||||
if (runState != CANNED_MESSAGE_RUN_STATE_INACTIVE &&
|
||||
(event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG)) {
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
freetext = "";
|
||||
cursor = 0;
|
||||
releaseFreetext();
|
||||
payload = 0;
|
||||
currentMessageIndex = -1;
|
||||
|
||||
@@ -762,8 +805,7 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo
|
||||
// Return to inactive state
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
|
||||
// Force display update to show normal screen
|
||||
UIFrameEvent e;
|
||||
@@ -822,8 +864,7 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
|
||||
// Cancel (dismiss freetext screen)
|
||||
if (event->inputEvent == INPUT_BROKER_LEFT) {
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
freetext = "";
|
||||
cursor = 0;
|
||||
releaseFreetext();
|
||||
payload = 0;
|
||||
currentMessageIndex = -1;
|
||||
|
||||
@@ -946,8 +987,7 @@ bool CannedMessageModule::handleFreeTextInput(const InputEvent *event)
|
||||
if (event->inputEvent == INPUT_BROKER_CANCEL || event->inputEvent == INPUT_BROKER_ALT_LONG ||
|
||||
(event->inputEvent == INPUT_BROKER_BACK && this->freetext.length() == 0)) {
|
||||
updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
freetext = "";
|
||||
cursor = 0;
|
||||
releaseFreetext();
|
||||
payload = 0;
|
||||
currentMessageIndex = -1;
|
||||
|
||||
@@ -1187,8 +1227,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
UIFrameEvent e;
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
this->notifyObservers(&e);
|
||||
return 2000;
|
||||
}
|
||||
@@ -1201,24 +1240,21 @@ int32_t CannedMessageModule::runOnce()
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
this->notifyObservers(&e);
|
||||
}
|
||||
// Handle SENDING_ACTIVE state transition after virtual keyboard message
|
||||
else if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == 0) {
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
return INT32_MAX;
|
||||
} else if (((this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) || (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT)) &&
|
||||
!Throttle::isWithinTimespanMs(this->lastTouchMillis, INACTIVATE_AFTER_MS)) {
|
||||
// Reset module on inactivity
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_INACTIVE);
|
||||
|
||||
// Clean up virtual keyboard if it exists during timeout
|
||||
@@ -1240,8 +1276,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
|
||||
// Clean up state but *don’t* deactivate yet
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
|
||||
// Tell Screen to jump straight to the TextMessage frame
|
||||
e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
|
||||
@@ -1267,8 +1302,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
|
||||
// Clean up state
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
|
||||
// Tell Screen to jump straight to the TextMessage frame
|
||||
e.action = UIFrameEvent::Action::SWITCH_TO_TEXTMESSAGE;
|
||||
@@ -1285,8 +1319,7 @@ int32_t CannedMessageModule::runOnce()
|
||||
}
|
||||
// fallback clean-up if nothing above returned
|
||||
this->currentMessageIndex = -1;
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
|
||||
e.action = UIFrameEvent::Action::REGENERATE_FRAMESET;
|
||||
this->notifyObservers(&e);
|
||||
@@ -1312,15 +1345,13 @@ int32_t CannedMessageModule::runOnce()
|
||||
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_UP) {
|
||||
if (this->messagesCount > 0) {
|
||||
this->currentMessageIndex = getPrevIndex();
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_ACTIVE);
|
||||
}
|
||||
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_DOWN) {
|
||||
if (this->messagesCount > 0) {
|
||||
this->currentMessageIndex = this->getNextIndex();
|
||||
this->freetext = "";
|
||||
this->cursor = 0;
|
||||
this->releaseFreetext();
|
||||
this->updateState(CANNED_MESSAGE_RUN_STATE_ACTIVE);
|
||||
}
|
||||
} else if (this->runState == CANNED_MESSAGE_RUN_STATE_FREETEXT || this->runState == CANNED_MESSAGE_RUN_STATE_ACTIVE) {
|
||||
@@ -1494,8 +1525,10 @@ void CannedMessageModule::drawKeyboard(OLEDDisplay *display, OLEDDisplayUiState
|
||||
|
||||
display->setColor(OLEDDISPLAY_COLOR::WHITE);
|
||||
|
||||
display->drawStringMaxWidth(0, 0, display->getWidth(),
|
||||
cannedMessageModule->drawWithCursor(cannedMessageModule->freetext, cannedMessageModule->cursor));
|
||||
char msgWithCursor[meshtastic_Constants_DATA_PAYLOAD_LEN + 2];
|
||||
cannedMessageModule->drawWithCursor(msgWithCursor, sizeof(msgWithCursor), cannedMessageModule->freetext.c_str(),
|
||||
cannedMessageModule->cursor);
|
||||
display->drawStringMaxWidth(0, 0, display->getWidth(), msgWithCursor);
|
||||
|
||||
display->setFont(FONT_MEDIUM);
|
||||
|
||||
@@ -1681,8 +1714,11 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
|
||||
// Header
|
||||
int titleY = 2;
|
||||
String titleText = "Select Destination";
|
||||
titleText += searchQuery.length() > 0 ? " [" + searchQuery + "]" : " [ ]";
|
||||
char titleText[64];
|
||||
if (searchQuery[0])
|
||||
snprintf(titleText, sizeof(titleText), "Select Destination [%s]", searchQuery);
|
||||
else
|
||||
snprintf(titleText, sizeof(titleText), "Select Destination [ ]");
|
||||
display->setTextAlignment(TEXT_ALIGN_CENTER);
|
||||
display->drawString(display->getWidth() / 2, titleY, titleText);
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
@@ -1709,26 +1745,27 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
|
||||
int xOffset = 0;
|
||||
int yOffset = row * (FONT_HEIGHT_SMALL - 4) + rowYOffset;
|
||||
std::string entryText;
|
||||
char entryText[96] = "";
|
||||
|
||||
// Draw Channels First
|
||||
if (itemIndex < numActiveChannels) {
|
||||
uint8_t channelIndex = this->activeChannelIndices[itemIndex];
|
||||
const char *channelName = channels.getName(channelIndex);
|
||||
entryText = std::string("#") + (channelName ? channelName : "?");
|
||||
snprintf(entryText, sizeof(entryText), "#%s", channelName ? channelName : "?");
|
||||
}
|
||||
// Then Draw Nodes
|
||||
else {
|
||||
int nodeIndex = itemIndex - numActiveChannels;
|
||||
if (nodeIndex >= 0 && nodeIndex < static_cast<int>(this->filteredNodes.size())) {
|
||||
meshtastic_NodeInfoLite *node = this->filteredNodes[nodeIndex].node;
|
||||
const char *nodeName = nullptr;
|
||||
if (node) {
|
||||
if (display->getWidth() <= 64) {
|
||||
entryText = node->short_name;
|
||||
nodeName = node->short_name;
|
||||
} else if (node->long_name[0]) {
|
||||
entryText = node->long_name;
|
||||
nodeName = node->long_name;
|
||||
} else {
|
||||
entryText = node->short_name;
|
||||
nodeName = node->short_name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1738,22 +1775,21 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
if (availWidth < 0)
|
||||
availWidth = 0;
|
||||
char truncatedEntry[96];
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, nodeName ? nodeName : "", truncatedEntry, sizeof(truncatedEntry),
|
||||
availWidth);
|
||||
entryText = truncatedEntry;
|
||||
snprintf(entryText, sizeof(entryText), "%s", truncatedEntry);
|
||||
|
||||
// Prepend "* " if this is a favorite
|
||||
if (nodeInfoLiteIsFavorite(node)) {
|
||||
entryText = "* " + entryText;
|
||||
snprintf(entryText, sizeof(entryText), "* %s", truncatedEntry);
|
||||
}
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText.c_str(), truncatedEntry, sizeof(truncatedEntry),
|
||||
availWidth);
|
||||
entryText = truncatedEntry;
|
||||
graphics::UIRenderer::truncateStringWithEmotes(display, entryText, truncatedEntry, sizeof(truncatedEntry), availWidth);
|
||||
snprintf(entryText, sizeof(entryText), "%s", truncatedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
if (entryText.empty() || entryText == "Unknown")
|
||||
entryText = "?";
|
||||
if (entryText[0] == '\0' || strcmp(entryText, "Unknown") == 0)
|
||||
snprintf(entryText, sizeof(entryText), "?");
|
||||
|
||||
// Highlight background (if selected)
|
||||
if (itemIndex == destIndex) {
|
||||
@@ -1763,7 +1799,7 @@ void CannedMessageModule::drawDestinationSelectionScreen(OLEDDisplay *display, O
|
||||
}
|
||||
|
||||
// Draw entry text
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText.c_str(), FONT_HEIGHT_SMALL, 1, false);
|
||||
graphics::UIRenderer::drawStringWithEmotes(display, xOffset + 2, yOffset, entryText, FONT_HEIGHT_SMALL, 1, false);
|
||||
display->setColor(WHITE);
|
||||
|
||||
// Draw key icon (after highlight)
|
||||
@@ -2022,8 +2058,9 @@ void CannedMessageModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *st
|
||||
display->setColor(WHITE);
|
||||
{
|
||||
int inputY = 0 + y + FONT_HEIGHT_SMALL;
|
||||
String msgWithCursor = this->drawWithCursor(this->freetext, this->cursor);
|
||||
drawWrappedEmoteText(display, x, inputY, msgWithCursor.c_str(), display->getWidth() - x, FONT_HEIGHT_SMALL);
|
||||
char msgWithCursor[meshtastic_Constants_DATA_PAYLOAD_LEN + 2];
|
||||
this->drawWithCursor(msgWithCursor, sizeof(msgWithCursor), this->freetext.c_str(), this->cursor);
|
||||
drawWrappedEmoteText(display, x, inputY, msgWithCursor, display->getWidth() - x, FONT_HEIGHT_SMALL);
|
||||
}
|
||||
#endif
|
||||
return;
|
||||
@@ -2368,10 +2405,27 @@ void CannedMessageModule::handleSetCannedMessageModuleMessages(const char *from_
|
||||
}
|
||||
}
|
||||
|
||||
String CannedMessageModule::drawWithCursor(String text, int cursor)
|
||||
void CannedMessageModule::drawWithCursor(char *buffer, size_t bufferSize, const char *text, size_t cursor)
|
||||
{
|
||||
String result = text.substring(0, cursor) + "_" + text.substring(cursor);
|
||||
return result;
|
||||
if (!buffer || bufferSize == 0)
|
||||
return;
|
||||
|
||||
const char *source = text ? text : "";
|
||||
const size_t sourceLen = strlen(source);
|
||||
const size_t cursorIndex = std::min(cursor, sourceLen);
|
||||
const size_t beforeLen = std::min(cursorIndex, bufferSize - 1);
|
||||
|
||||
memcpy(buffer, source, beforeLen);
|
||||
size_t outLen = beforeLen;
|
||||
|
||||
if (outLen < bufferSize - 1)
|
||||
buffer[outLen++] = '_';
|
||||
|
||||
const size_t remaining = bufferSize - 1 - outLen;
|
||||
const size_t afterLen = std::min(sourceLen - cursorIndex, remaining);
|
||||
memcpy(buffer + outLen, source + cursorIndex, afterLen);
|
||||
outLen += afterLen;
|
||||
buffer[outLen] = '\0';
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -75,7 +75,7 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
void updateDestinationSelectionList();
|
||||
void drawDestinationSelectionScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
|
||||
bool isCharInputAllowed() const;
|
||||
String drawWithCursor(String text, int cursor);
|
||||
void drawWithCursor(char *buffer, size_t bufferSize, const char *text, size_t cursor);
|
||||
|
||||
// === Emote Picker ===
|
||||
int handleEmotePickerInput(const InputEvent *event);
|
||||
@@ -146,7 +146,8 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
int visibleRows = 0;
|
||||
bool needsUpdate = true;
|
||||
unsigned long lastUpdateMillis = 0;
|
||||
String searchQuery;
|
||||
static constexpr size_t searchQuerySize = 33;
|
||||
char searchQuery[searchQuerySize] = {0};
|
||||
String freetext;
|
||||
|
||||
// === Message Storage ===
|
||||
@@ -175,6 +176,9 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
unsigned long lastTouchMillis = 0;
|
||||
uint32_t lastFilterUpdate = 0;
|
||||
static constexpr uint32_t filterDebounceMs = 30;
|
||||
size_t cachedDestinationNodeCount = 0;
|
||||
char cachedDestinationSearchQuery[searchQuerySize] = {0};
|
||||
bool destinationSelectionCacheValid = false;
|
||||
std::vector<uint8_t> activeChannelIndices;
|
||||
std::vector<NodeEntry> filteredNodes;
|
||||
|
||||
@@ -184,6 +188,8 @@ class CannedMessageModule : public SinglePortModule, public Observable<const UIF
|
||||
#endif
|
||||
|
||||
void updateState(cannedMessageModuleRunState, bool shouldRequestFocus = false);
|
||||
void releaseDestinationSelectionCache();
|
||||
void releaseFreetext();
|
||||
|
||||
bool isUpEvent(const InputEvent *event);
|
||||
bool isDownEvent(const InputEvent *event);
|
||||
|
||||
@@ -158,8 +158,8 @@ meshtastic_MeshPacket *NodeInfoModule::allocReply()
|
||||
ignoreRequest = true;
|
||||
return NULL;
|
||||
} else {
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User &u = owner;
|
||||
ignoreRequest = false; // Don't ignore requests anymore
|
||||
meshtastic_User u = owner; // deliberate copy: the licensed strip below must not clobber the global owner state
|
||||
|
||||
// Strip the public key if the user is licensed
|
||||
if (u.is_licensed && u.public_key.size > 0) {
|
||||
|
||||
@@ -193,24 +193,36 @@ void OnScreenKeyboardModule::drawPopup(OLEDDisplay *display)
|
||||
display->setTextAlignment(TEXT_ALIGN_LEFT);
|
||||
const uint16_t maxWrapWidth = display->width() - 40;
|
||||
|
||||
auto wrapText = [&](const char *text, uint16_t availableWidth) -> std::vector<std::string> {
|
||||
std::vector<std::string> wrapped;
|
||||
char lineStorage[maxContentLines + 1][64] = {};
|
||||
const char *linePtrs[maxContentLines + 2] = {};
|
||||
uint8_t lineCount = 0;
|
||||
|
||||
auto appendLine = [&](const std::string &line) {
|
||||
if (line.empty() || lineCount >= maxContentLines + (hasTitle ? 1 : 0))
|
||||
return;
|
||||
strncpy(lineStorage[lineCount], line.c_str(), sizeof(lineStorage[lineCount]) - 1);
|
||||
lineStorage[lineCount][sizeof(lineStorage[lineCount]) - 1] = '\0';
|
||||
linePtrs[lineCount] = lineStorage[lineCount];
|
||||
++lineCount;
|
||||
};
|
||||
|
||||
auto wrapText = [&](const char *text, uint16_t availableWidth) {
|
||||
std::string current;
|
||||
std::string word;
|
||||
const char *p = text;
|
||||
while (*p && wrapped.size() < maxContentLines) {
|
||||
while (*p && lineCount < maxContentLines + (hasTitle ? 1 : 0)) {
|
||||
while (*p && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')) {
|
||||
if (*p == '\n') {
|
||||
if (!current.empty()) {
|
||||
wrapped.push_back(current);
|
||||
appendLine(current);
|
||||
current.clear();
|
||||
if (wrapped.size() >= maxContentLines)
|
||||
if (lineCount >= maxContentLines + (hasTitle ? 1 : 0))
|
||||
break;
|
||||
}
|
||||
}
|
||||
++p;
|
||||
}
|
||||
if (!*p || wrapped.size() >= maxContentLines)
|
||||
if (!*p || lineCount >= maxContentLines + (hasTitle ? 1 : 0))
|
||||
break;
|
||||
word.clear();
|
||||
while (*p && *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r')
|
||||
@@ -223,9 +235,9 @@ void OnScreenKeyboardModule::drawPopup(OLEDDisplay *display)
|
||||
current = test;
|
||||
else {
|
||||
if (!current.empty()) {
|
||||
wrapped.push_back(current);
|
||||
appendLine(current);
|
||||
current = word;
|
||||
if (wrapped.size() >= maxContentLines)
|
||||
if (lineCount >= maxContentLines + (hasTitle ? 1 : 0))
|
||||
break;
|
||||
} else {
|
||||
current = word;
|
||||
@@ -235,36 +247,26 @@ void OnScreenKeyboardModule::drawPopup(OLEDDisplay *display)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!current.empty() && wrapped.size() < maxContentLines)
|
||||
wrapped.push_back(current);
|
||||
return wrapped;
|
||||
if (!current.empty() && lineCount < maxContentLines + (hasTitle ? 1 : 0))
|
||||
appendLine(current);
|
||||
};
|
||||
|
||||
std::vector<std::string> allLines;
|
||||
if (hasTitle)
|
||||
allLines.emplace_back(popupTitle);
|
||||
appendLine(popupTitle);
|
||||
|
||||
char buf[sizeof(popupMessage)];
|
||||
strncpy(buf, popupMessage, sizeof(buf) - 1);
|
||||
buf[sizeof(buf) - 1] = '\0';
|
||||
char *paragraph = strtok(buf, "\n");
|
||||
while (paragraph && allLines.size() < maxContentLines + (hasTitle ? 1 : 0)) {
|
||||
auto wrapped = wrapText(paragraph, maxWrapWidth);
|
||||
for (const auto &ln : wrapped) {
|
||||
if (allLines.size() >= maxContentLines + (hasTitle ? 1 : 0))
|
||||
break;
|
||||
allLines.push_back(ln);
|
||||
}
|
||||
while (paragraph && lineCount < maxContentLines + (hasTitle ? 1 : 0)) {
|
||||
wrapText(paragraph, maxWrapWidth);
|
||||
paragraph = strtok(nullptr, "\n");
|
||||
}
|
||||
|
||||
std::vector<const char *> ptrs;
|
||||
for (const auto &ln : allLines)
|
||||
ptrs.push_back(ln.c_str());
|
||||
ptrs.push_back(nullptr);
|
||||
linePtrs[lineCount] = nullptr;
|
||||
|
||||
// Use the standard notification box drawing from NotificationRenderer
|
||||
NotificationRenderer::drawNotificationBox(display, nullptr, ptrs.data(), allLines.size(), 0, 0);
|
||||
NotificationRenderer::drawNotificationBox(display, nullptr, linePtrs, lineCount, 0, 0);
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
|
||||
@@ -21,7 +21,7 @@ void TraceRouteModule::setResultText(const String &text)
|
||||
|
||||
void TraceRouteModule::clearResultLines()
|
||||
{
|
||||
resultLines.clear();
|
||||
std::vector<String>().swap(resultLines);
|
||||
resultLinesDirty = false;
|
||||
}
|
||||
#if HAS_SCREEN
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
// meshtastic_ServiceEnvelope that automatically releases dynamically allocated memory when it goes out of scope.
|
||||
struct DecodedServiceEnvelope : public meshtastic_ServiceEnvelope {
|
||||
DecodedServiceEnvelope(const uint8_t *payload, size_t length);
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &) = delete;
|
||||
// const-qualified so std::variant instantiation works on Apple libc++ (copying stays ill-formed either way)
|
||||
DecodedServiceEnvelope(const DecodedServiceEnvelope &) = delete;
|
||||
DecodedServiceEnvelope(DecodedServiceEnvelope &&);
|
||||
~DecodedServiceEnvelope();
|
||||
// Clients must check that this is true before using.
|
||||
|
||||
@@ -40,6 +40,7 @@ constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8;
|
||||
|
||||
BLECharacteristic *fromNumCharacteristic;
|
||||
BLECharacteristic *BatteryCharacteristic;
|
||||
static int lastBatteryLevel = -1; // last value written to 0x2A19, to skip redundant writes/notifies
|
||||
BLECharacteristic *logRadioCharacteristic;
|
||||
BLEServer *bleServer;
|
||||
|
||||
@@ -718,6 +719,8 @@ void NimbleBluetooth::deinit()
|
||||
#endif
|
||||
|
||||
BLEDevice::deinit(true);
|
||||
BatteryCharacteristic = nullptr; // freed by deinit; clear so updateBatteryLevel() won't touch it
|
||||
lastBatteryLevel = -1;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -856,16 +859,31 @@ void NimbleBluetooth::setupService()
|
||||
BatteryCharacteristic = batteryService->createCharacteristic( // 0x2A19 is the Battery Level characteristic)
|
||||
(uint16_t)0x2a19, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
|
||||
BatteryCharacteristic->addDescriptor(batteryLevelDescriptor);
|
||||
// Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value.
|
||||
uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0;
|
||||
if (initialLevel > 100)
|
||||
initialLevel = 100;
|
||||
BatteryCharacteristic->setValue(&initialLevel, 1);
|
||||
lastBatteryLevel = initialLevel;
|
||||
batteryService->start();
|
||||
}
|
||||
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if ((config.bluetooth.enabled == true) && nimbleBluetooth && nimbleBluetooth->isConnected()) {
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (!config.bluetooth.enabled || !BatteryCharacteristic)
|
||||
return;
|
||||
|
||||
if (level > 100) // 0x2A19 must stay within the BAS 0-100 range
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
|
||||
// Cache the value so a READ works without a subscriber; notify only when connected.
|
||||
BatteryCharacteristic->setValue(&level, 1);
|
||||
if (nimbleBluetooth && nimbleBluetooth->isConnected())
|
||||
BatteryCharacteristic->notify();
|
||||
}
|
||||
}
|
||||
|
||||
void NimbleBluetooth::clearBonds()
|
||||
|
||||
@@ -15,8 +15,9 @@ static BLECharacteristic fromRadio = BLECharacteristic(BLEUuid(FROMRADIO_UUID_16
|
||||
static BLECharacteristic toRadio = BLECharacteristic(BLEUuid(TORADIO_UUID_16));
|
||||
static BLECharacteristic logRadio = BLECharacteristic(BLEUuid(LOGRADIO_UUID_16));
|
||||
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static BLEDis bledis; // DIS (Device Information Service) helper class instance
|
||||
static BLEBas blebas; // BAS (Battery Service) helper class instance
|
||||
static int lastBatteryLevel = -1; // last value written to BAS, to skip redundant writes/notifies
|
||||
#ifndef BLE_DFU_SECURE
|
||||
static BLEDfu bledfu; // DFU software update helper service
|
||||
#else
|
||||
@@ -336,6 +337,7 @@ void NRF52Bluetooth::setup()
|
||||
LOG_INFO("Init the Battery Service");
|
||||
blebas.begin();
|
||||
blebas.write(0); // Unknown battery level for now
|
||||
lastBatteryLevel = 0;
|
||||
// Setup the Heart Rate Monitor service using
|
||||
// BLEService and BLECharacteristic classes
|
||||
LOG_INFO("Init the Mesh bluetooth service");
|
||||
@@ -355,6 +357,14 @@ void NRF52Bluetooth::resumeAdvertising()
|
||||
/// Given a level between 0-100, update the BLE attribute
|
||||
void updateBatteryLevel(uint8_t level)
|
||||
{
|
||||
if (!nrf52Bluetooth) // skip until the Battery Service has been begun in setup()
|
||||
return;
|
||||
|
||||
if (level > 100) // BAS battery level must stay within 0-100
|
||||
level = 100;
|
||||
if (level == lastBatteryLevel)
|
||||
return;
|
||||
lastBatteryLevel = level;
|
||||
blebas.write(level);
|
||||
}
|
||||
void NRF52Bluetooth::clearBonds()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "TestUtil.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
|
||||
static void test_placeholder()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "TestUtil.h"
|
||||
#include <cstdlib>
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
|
||||
@@ -55,7 +55,6 @@ build_flags_common =
|
||||
-lyaml-cpp
|
||||
-ljsoncpp
|
||||
!pkg-config --cflags jsoncpp --silence-errors || :
|
||||
-li2c
|
||||
-luv
|
||||
-std=gnu17
|
||||
-std=gnu++17
|
||||
|
||||
Reference in New Issue
Block a user