#include "configuration.h" #if HAS_SCREEN #include "DisplayFormatters.h" #include "NodeDB.h" #include "NotificationRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" #include "input/RotaryEncoderInterruptImpl1.h" #include "input/UpDownInterruptImpl1.h" #if HAS_BUTTON #include "input/ButtonThread.h" #endif #include "main.h" #include #include #include #if HAS_TRACKBALL #include "input/TrackballInterruptImpl1.h" #endif #ifdef ARCH_ESP32 #include "esp_task_wdt.h" #endif using namespace meshtastic; #if HAS_BUTTON // Global button thread pointer defined in main.cpp extern ::ButtonThread *UserButtonThread; #endif // External references to global variables from Screen.cpp extern std::vector functionSymbol; extern std::string functionSymbolString; extern bool hasUnreadMessage; namespace graphics { 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 const char **NotificationRenderer::optionsArrayPtr = nullptr; const int *NotificationRenderer::optionsEnumPtr = nullptr; std::function NotificationRenderer::alertBannerCallback = NULL; bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; // Variables for message popup overlay on virtual keyboard char NotificationRenderer::keyboardPopupMessage[256] = {0}; char NotificationRenderer::keyboardPopupTitle[64] = {0}; uint32_t NotificationRenderer::keyboardPopupUntil = 0; bool NotificationRenderer::showKeyboardPopup = false; uint32_t NotificationRenderer::numDigits = 0; uint32_t NotificationRenderer::currentNumber = 0; VirtualKeyboard *NotificationRenderer::virtualKeyboard = nullptr; std::function NotificationRenderer::textInputCallback = nullptr; uint32_t pow_of_10(uint32_t n) { uint32_t ret = 1; for (uint32_t i = 0; i < n; i++) { ret *= 10; } return ret; } // Used on boot when a certificate is being created void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_SMALL); display->drawString(64 + x, y, "Creating SSL certificate"); #ifdef ARCH_ESP32 yield(); esp_task_wdt_reset(); #endif display->setFont(FONT_SMALL); if ((millis() / 1000) % 2) { display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . ."); } else { display->drawString(64 + x, FONT_HEIGHT_SMALL + y + 2, "Please wait . . "); } } void NotificationRenderer::resetBanner() { notificationTypeEnum previousType = current_notification_type; alertBannerMessage[0] = '\0'; current_notification_type = notificationTypeEnum::none; // Reset keyboard popup message variables keyboardPopupMessage[0] = '\0'; keyboardPopupTitle[0] = '\0'; keyboardPopupUntil = 0; showKeyboardPopup = false; inEvent.inputEvent = INPUT_BROKER_NONE; inEvent.kbchar = 0; curSelected = 0; alertBannerOptions = 0; // last x lines are seelctable options optionsArrayPtr = nullptr; optionsEnumPtr = nullptr; alertBannerCallback = NULL; pauseBanner = false; numDigits = 0; currentNumber = 0; nodeDB->pause_sort(false); // If we're exiting from text_input (virtual keyboard), trigger frame update // to ensure any messages received during keyboard use are now displayed if (previousType == notificationTypeEnum::text_input && screen) { screen->setFrames(graphics::Screen::FOCUS_PRESERVE); } } void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state) { // Handle text_input notifications first - they have their own timeout/banner logic if (current_notification_type == notificationTypeEnum::text_input) { // Check for timeout and reset if needed for text input if (millis() > alertBannerUntil && alertBannerUntil > 0) { resetBanner(); return; } drawTextInput(display, state); return; } if (millis() > alertBannerUntil && alertBannerUntil > 0) { resetBanner(); } // Exit if no banner is showing or banner is paused if (!isOverlayBannerShowing() || pauseBanner) { return; } switch (current_notification_type) { case notificationTypeEnum::none: // Do nothing - no notification to display break; case notificationTypeEnum::text_input: // Already handled above with dedicated logic (early return). Keep a case here to satisfy -Wswitch. break; case notificationTypeEnum::text_banner: case notificationTypeEnum::selection_picker: drawAlertBannerOverlay(display, state); break; case notificationTypeEnum::node_picker: drawNodePicker(display, state); break; case notificationTypeEnum::number_picker: drawNumberPicker(display, state); break; } } void NotificationRenderer::drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state) { const char *lineStarts[MAX_LINES + 1] = {0}; uint16_t lineCount = 0; // Parse lines char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); lineStarts[lineCount] = alertBannerMessage; // Find lines while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); if (lineStarts[lineCount + 1][0] == '\n') lineStarts[lineCount + 1] += 1; lineCount++; } // modulo to extract uint8_t this_digit = (currentNumber % (pow_of_10(numDigits - curSelected))) / (pow_of_10(numDigits - curSelected - 1)); // Handle input if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { if (this_digit == 9) { currentNumber -= 9 * (pow_of_10(numDigits - curSelected - 1)); } else { currentNumber += (pow_of_10(numDigits - curSelected - 1)); } } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { if (this_digit == 0) { currentNumber += 9 * (pow_of_10(numDigits - curSelected - 1)); } else { currentNumber -= (pow_of_10(numDigits - curSelected - 1)); } } else if (inEvent.inputEvent == INPUT_BROKER_ANYKEY) { if (inEvent.kbchar > 47 && inEvent.kbchar < 58) { // have a digit currentNumber -= this_digit * (pow_of_10(numDigits - curSelected - 1)); currentNumber += (inEvent.kbchar - 48) * (pow_of_10(numDigits - curSelected - 1)); curSelected++; } } else if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_RIGHT) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { curSelected--; } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { resetBanner(); return; } if (curSelected == static_cast(numDigits)) { alertBannerCallback(currentNumber); resetBanner(); return; } inEvent.inputEvent = INPUT_BROKER_NONE; if (alertBannerMessage[0] == '\0') return; uint16_t totalLines = lineCount + 2; const char *linePointers[totalLines + 1] = {0}; // this is sort of a dynamic allocation // copy the linestarts to display to the linePointers holder for (uint16_t i = 0; i < lineCount; i++) { linePointers[i] = lineStarts[i]; } std::string digits = " "; std::string arrowPointer = " "; for (uint16_t i = 0; i < numDigits; i++) { // Modulo minus modulo to return just the current number digits += std::to_string((currentNumber % (pow_of_10(numDigits - i))) / (pow_of_10(numDigits - i - 1))) + " "; if (curSelected == i) { arrowPointer += "^ "; } else { arrowPointer += "_ "; } } linePointers[lineCount++] = digits.c_str(); linePointers[lineCount++] = arrowPointer.c_str(); drawNotificationBox(display, state, linePointers, totalLines, 0); } void NotificationRenderer::drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state) { static uint32_t selectedNodenum = 0; // === Layout Configuration === constexpr uint16_t vPadding = 2; alertBannerOptions = nodeDB->getNumMeshNodes() - 1; // let the box drawing function calculate the widths? const char *lineStarts[MAX_LINES + 1] = {0}; uint16_t lineCount = 0; // Parse lines char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); lineStarts[lineCount] = alertBannerMessage; while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); if (lineStarts[lineCount + 1][0] == '\n') lineStarts[lineCount + 1] += 1; lineCount++; } // Handle input if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { curSelected--; } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { alertBannerCallback(selectedNodenum); resetBanner(); return; } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { resetBanner(); return; } if (curSelected == -1) curSelected = alertBannerOptions - 1; if (curSelected == alertBannerOptions) curSelected = 0; inEvent.inputEvent = INPUT_BROKER_NONE; if (alertBannerMessage[0] == '\0') return; uint16_t totalLines = lineCount + alertBannerOptions; uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); uint8_t linesShown = lineCount; const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation // copy the linestarts to display to the linePointers holder for (int i = 0; i < lineCount; i++) { linePointers[i] = lineStarts[i]; } char scratchLineBuffer[visibleTotalLines - lineCount][40]; uint8_t firstOptionToShow = 0; if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) { if (curSelected > alertBannerOptions - visibleTotalLines + lineCount) firstOptionToShow = alertBannerOptions - visibleTotalLines + lineCount; else firstOptionToShow = curSelected - 1; } else { firstOptionToShow = 0; } 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); } else { snprintf(temp_name, sizeof(temp_name), "(%04X)", (uint16_t)(nodeDB->getMeshNodeByIndex(i + 1)->num & 0xFFFF)); } if (i == curSelected) { selectedNodenum = nodeDB->getMeshNodeByIndex(i + 1)->num; if (isHighResolution) { strncpy(scratchLineBuffer[scratchLineNum], "> ", 3); strncpy(scratchLineBuffer[scratchLineNum] + 2, temp_name, 36); strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 2, " <", 3); } else { strncpy(scratchLineBuffer[scratchLineNum], ">", 2); strncpy(scratchLineBuffer[scratchLineNum] + 1, temp_name, 37); strncpy(scratchLineBuffer[scratchLineNum] + strlen(temp_name) + 1, "<", 2); } scratchLineBuffer[scratchLineNum][39] = '\0'; } else { strncpy(scratchLineBuffer[scratchLineNum], temp_name, 39); scratchLineBuffer[scratchLineNum][39] = '\0'; } linePointers[linesShown] = scratchLineBuffer[scratchLineNum++]; } drawNotificationBox(display, state, linePointers, totalLines, firstOptionToShow); } void NotificationRenderer::drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state) { // === Layout Configuration === constexpr uint16_t vPadding = 2; uint16_t optionWidths[alertBannerOptions] = {0}; uint16_t maxWidth = 0; uint16_t arrowsWidth = display->getStringWidth("> <", 4, true); uint16_t lineWidths[MAX_LINES] = {0}; uint16_t lineLengths[MAX_LINES] = {0}; const char *lineStarts[MAX_LINES + 1] = {0}; uint16_t lineCount = 0; char lineBuffer[40] = {0}; // Parse lines char *alertEnd = alertBannerMessage + strnlen(alertBannerMessage, sizeof(alertBannerMessage)); lineStarts[lineCount] = alertBannerMessage; while ((lineCount < MAX_LINES) && (lineStarts[lineCount] < alertEnd)) { lineStarts[lineCount + 1] = std::find((char *)lineStarts[lineCount], alertEnd, '\n'); lineLengths[lineCount] = lineStarts[lineCount + 1] - lineStarts[lineCount]; if (lineStarts[lineCount + 1][0] == '\n') lineStarts[lineCount + 1] += 1; lineWidths[lineCount] = display->getStringWidth(lineStarts[lineCount], lineLengths[lineCount], true); if (lineWidths[lineCount] > maxWidth) maxWidth = lineWidths[lineCount]; lineCount++; } // Measure option widths for (int i = 0; i < alertBannerOptions; i++) { optionWidths[i] = display->getStringWidth(optionsArrayPtr[i], strlen(optionsArrayPtr[i]), true); if (optionWidths[i] > maxWidth) maxWidth = optionWidths[i]; if (optionWidths[i] + arrowsWidth > maxWidth) maxWidth = optionWidths[i] + arrowsWidth; } // Handle input if (alertBannerOptions > 0) { if (inEvent.inputEvent == INPUT_BROKER_UP || inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { curSelected--; } else if (inEvent.inputEvent == INPUT_BROKER_DOWN || inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { curSelected++; } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { if (optionsEnumPtr != nullptr) { alertBannerCallback(optionsEnumPtr[curSelected]); optionsEnumPtr = nullptr; } else { alertBannerCallback(curSelected); } resetBanner(); return; } else if ((inEvent.inputEvent == INPUT_BROKER_CANCEL || inEvent.inputEvent == INPUT_BROKER_ALT_LONG) && alertBannerUntil != 0) { resetBanner(); return; } if (curSelected == -1) curSelected = alertBannerOptions - 1; if (curSelected == alertBannerOptions) curSelected = 0; } else { if (inEvent.inputEvent == INPUT_BROKER_SELECT || inEvent.inputEvent == INPUT_BROKER_ALT_LONG || inEvent.inputEvent == INPUT_BROKER_CANCEL) { resetBanner(); return; } } inEvent.inputEvent = INPUT_BROKER_NONE; if (alertBannerMessage[0] == '\0') return; uint16_t totalLines = lineCount + alertBannerOptions; uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = std::min(totalLines, (screenHeight - vPadding * 2) / effectiveLineHeight); uint8_t linesShown = lineCount; const char *linePointers[visibleTotalLines + 1] = {0}; // this is sort of a dynamic allocation // copy the linestarts to display to the linePointers holder for (int i = 0; i < lineCount; i++) { linePointers[i] = lineStarts[i]; } uint8_t firstOptionToShow = 0; if (alertBannerOptions > 0) { if (visibleTotalLines - lineCount == 1) { firstOptionToShow = curSelected; } else if (curSelected > 1 && alertBannerOptions > visibleTotalLines - lineCount) { if (curSelected > alertBannerOptions - visibleTotalLines + lineCount) firstOptionToShow = alertBannerOptions - visibleTotalLines + lineCount; else firstOptionToShow = curSelected - 1; } else { firstOptionToShow = 0; } } // Useful log line for troubleshooting: /* LOG_WARN("alertBannerOptions: %u, curSelected: %u, visibleTotalLines: %u, lineCount: %u, firstOptionToShow: %u", alertBannerOptions, curSelected, visibleTotalLines, lineCount, firstOptionToShow); */ for (int i = firstOptionToShow; i < alertBannerOptions && linesShown < visibleTotalLines; i++, linesShown++) { if (i == curSelected) { if (isHighResolution) { strncpy(lineBuffer, "> ", 3); strncpy(lineBuffer + 2, optionsArrayPtr[i], 36); strncpy(lineBuffer + strlen(optionsArrayPtr[i]) + 2, " <", 3); } else { strncpy(lineBuffer, ">", 2); strncpy(lineBuffer + 1, optionsArrayPtr[i], 37); strncpy(lineBuffer + strlen(optionsArrayPtr[i]) + 1, "<", 2); } lineBuffer[39] = '\0'; linePointers[linesShown] = lineBuffer; } else { linePointers[linesShown] = optionsArrayPtr[i]; } } if (alertBannerOptions > 0) { drawNotificationBox(display, state, linePointers, totalLines, firstOptionToShow, maxWidth); } else { drawNotificationBox(display, state, linePointers, totalLines, firstOptionToShow); } } void NotificationRenderer::drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[], uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth) { bool is_picker = false; uint16_t lineCount = 0; // === Layout Configuration === constexpr uint16_t hPadding = 5; constexpr uint16_t vPadding = 2; bool needs_bell = false; uint16_t lineWidths[totalLines] = {0}; uint16_t lineLengths[totalLines] = {0}; if (maxWidth != 0) is_picker = true; // Setup font and alignment display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); while (lines[lineCount] != nullptr) { auto newlinePointer = strchr(lines[lineCount], '\n'); if (newlinePointer) lineLengths[lineCount] = (newlinePointer - lines[lineCount]); // Check for newlines first 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 (!is_picker) { needs_bell |= (strstr(alertBannerMessage, "Alert Received") != nullptr); if (lineWidths[lineCount] > maxWidth) maxWidth = lineWidths[lineCount]; } lineCount++; } // count lines uint16_t boxWidth = hPadding * 2 + maxWidth; if (needs_bell) { if (isHighResolution && boxWidth <= 150) boxWidth += 26; if (!isHighResolution && boxWidth <= 100) boxWidth += 20; } uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = std::min(lineCount, (screenHeight - vPadding * 2) / effectiveLineHeight); uint16_t contentHeight = visibleTotalLines * effectiveLineHeight; uint16_t boxHeight = contentHeight + vPadding * 2; if (visibleTotalLines == 1) { boxHeight += (isHighResolution) ? 4 : 3; } int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); if (totalLines > visibleTotalLines) { boxWidth += (isHighResolution) ? 4 : 2; } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); // === Draw Box === display->setColor(BLACK); display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); display->fillRect(boxLeft, boxTop - 2, boxWidth, 1); display->fillRect(boxLeft, boxTop + boxHeight + 1, boxWidth, 1); display->fillRect(boxLeft - 2, boxTop, 1, boxHeight); display->fillRect(boxLeft + boxWidth + 1, boxTop, 1, boxHeight); display->setColor(WHITE); display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); display->setColor(BLACK); display->fillRect(boxLeft, boxTop, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop, 1, 1); display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); display->setColor(WHITE); // === Draw Content === int16_t lineY = boxTop + vPadding; for (int i = 0; i < lineCount; i++) { int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; if (needs_bell && i == 0) { int bellY = lineY + (FONT_HEIGHT_SMALL - 8) / 2; display->drawXbm(textX - 10, bellY, 8, 8, bell_alert); display->drawXbm(textX + lineWidths[i] + 2, bellY, 8, 8, bell_alert); } char lineBuffer[lineLengths[i] + 1]; strncpy(lineBuffer, lines[i], lineLengths[i]); lineBuffer[lineLengths[i]] = '\0'; // Determine if this is a pop-up or a pick list if (alertBannerOptions > 0 && i == 0) { // Pick List display->setColor(WHITE); int background_yOffset = 1; // Determine if we have low hanging characters if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) { background_yOffset = -1; } display->fillRect(boxLeft, boxTop + 1, boxWidth, effectiveLineHeight - background_yOffset); display->setColor(BLACK); int yOffset = 3; display->drawString(textX, lineY - yOffset, lineBuffer); display->setColor(WHITE); lineY += (effectiveLineHeight - 2 - background_yOffset); } else { // Pop-up display->drawString(textX, lineY, lineBuffer); lineY += (effectiveLineHeight); } } // === Scroll Bar (Thicker, inside box, not over title) === if (totalLines > visibleTotalLines) { const uint8_t scrollBarWidth = 5; int16_t scrollBarX = boxLeft + boxWidth - scrollBarWidth - 2; int16_t scrollBarY = boxTop + vPadding + effectiveLineHeight; // start after title line uint16_t scrollBarHeight = boxHeight - vPadding * 2 - effectiveLineHeight; float ratio = (float)visibleTotalLines / totalLines; uint16_t indicatorHeight = std::max((int)(scrollBarHeight * ratio), 4); float scrollRatio = (float)(firstOptionToShow + lineCount - visibleTotalLines) / (totalLines - visibleTotalLines); uint16_t indicatorY = scrollBarY + scrollRatio * (scrollBarHeight - indicatorHeight); display->drawRect(scrollBarX, scrollBarY, scrollBarWidth, scrollBarHeight); display->fillRect(scrollBarX + 1, indicatorY, scrollBarWidth - 2, indicatorHeight); } } /// Draw the last text message we received void NotificationRenderer::drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_MEDIUM); char tempBuf[24]; snprintf(tempBuf, sizeof(tempBuf), "Critical fault #%d", error_code); display->drawString(0 + x, 0 + y, tempBuf); display->setTextAlignment(TEXT_ALIGN_LEFT); display->setFont(FONT_SMALL); display->drawString(0 + x, FONT_HEIGHT_MEDIUM + y, "For help, please visit \nmeshtastic.org"); } void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { display->setTextAlignment(TEXT_ALIGN_CENTER); display->setFont(FONT_MEDIUM); display->drawString(64 + x, y, "Updating"); display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); display->drawStringMaxWidth(0 + x, 2 + y + FONT_HEIGHT_SMALL * 2, x + display->getWidth(), "Please be patient and do not power off."); } void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state) { if (virtualKeyboard) { // Check for timeout and auto-exit if needed if (virtualKeyboard->isTimedOut()) { LOG_INFO("Virtual keyboard timeout - auto-exiting"); // Cancel virtual keyboard - call callback with empty string to indicate timeout auto callback = textInputCallback; // Store callback before clearing // Clean up first to prevent re-entry delete virtualKeyboard; virtualKeyboard = nullptr; textInputCallback = nullptr; resetBanner(); // Call callback after cleanup if (callback) { callback(""); } // Restore normal overlays if (screen) { screen->setFrames(graphics::Screen::FOCUS_PRESERVE); } return; } if (inEvent.inputEvent != INPUT_BROKER_NONE) { if (inEvent.inputEvent == INPUT_BROKER_UP) { // high frequency for move cursor left/right than up/down with encoders extern ::RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; extern ::UpDownInterruptImpl1 *upDownInterruptImpl1; if (::rotaryEncoderInterruptImpl1 || ::upDownInterruptImpl1) { virtualKeyboard->moveCursorLeft(); } else { virtualKeyboard->moveCursorUp(); } } else if (inEvent.inputEvent == INPUT_BROKER_DOWN) { extern ::RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; extern ::UpDownInterruptImpl1 *upDownInterruptImpl1; if (::rotaryEncoderInterruptImpl1 || ::upDownInterruptImpl1) { virtualKeyboard->moveCursorRight(); } else { virtualKeyboard->moveCursorDown(); } } else if (inEvent.inputEvent == INPUT_BROKER_LEFT) { virtualKeyboard->moveCursorLeft(); } else if (inEvent.inputEvent == INPUT_BROKER_RIGHT) { virtualKeyboard->moveCursorRight(); } else if (inEvent.inputEvent == INPUT_BROKER_UP_LONG) { virtualKeyboard->moveCursorUp(); } else if (inEvent.inputEvent == INPUT_BROKER_DOWN_LONG) { virtualKeyboard->moveCursorDown(); } else if (inEvent.inputEvent == INPUT_BROKER_ALT_PRESS) { virtualKeyboard->moveCursorLeft(); } else if (inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { virtualKeyboard->moveCursorRight(); } else if (inEvent.inputEvent == INPUT_BROKER_SELECT) { virtualKeyboard->handlePress(); } else if (inEvent.inputEvent == INPUT_BROKER_SELECT_LONG) { virtualKeyboard->handleLongPress(); } else if (inEvent.inputEvent == INPUT_BROKER_CANCEL) { auto callback = textInputCallback; delete virtualKeyboard; virtualKeyboard = nullptr; textInputCallback = nullptr; resetBanner(); if (callback) { callback(""); } if (screen) { screen->setFrames(graphics::Screen::FOCUS_PRESERVE); } return; } // Consume the event after processing for virtual keyboard inEvent.inputEvent = INPUT_BROKER_NONE; } // Clear the screen to avoid overlapping with underlying frames or overlays display->setColor(BLACK); display->fillRect(0, 0, display->getWidth(), display->getHeight()); display->setColor(WHITE); // Draw the virtual keyboard virtualKeyboard->draw(display, 0, 0); // Draw message popup overlay if active drawKeyboardMessagePopup(display); } else { // If virtualKeyboard is null, reset the banner to avoid getting stuck LOG_INFO("Virtual keyboard is null - resetting banner"); resetBanner(); } } bool NotificationRenderer::isOverlayBannerShowing() { return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); } void NotificationRenderer::showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs) { if (!title || !content || current_notification_type != notificationTypeEnum::text_input) { return; } strncpy(keyboardPopupTitle, title, 63); keyboardPopupTitle[63] = '\0'; strncpy(keyboardPopupMessage, content, 255); keyboardPopupMessage[255] = '\0'; keyboardPopupUntil = millis() + durationMs; showKeyboardPopup = true; } void NotificationRenderer::drawKeyboardMessagePopup(OLEDDisplay *display) { // Check if popup should be dismissed if (!showKeyboardPopup || millis() > keyboardPopupUntil || keyboardPopupMessage[0] == '\0') { showKeyboardPopup = false; return; } // === Use drawNotificationBox for true BannerOverlayOptions style === constexpr uint16_t maxContentLines = 3; // Maximum content lines bool hasTitle = keyboardPopupTitle[0] != '\0'; display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); // Conservative wrap width to ensure we never exceed screen after padding const uint16_t maxWrapWidth = display->width() - 40; // Leave room for padding and bell icons // Prepare content for display - build lines array for drawNotificationBox std::vector allLines; // Parse message content into lines with word wrapping char contentBuffer[256]; strncpy(contentBuffer, keyboardPopupMessage, 255); contentBuffer[255] = '\0'; // Helper function to wrap text to fit within available width auto wrapText = [&](const char *text, uint16_t availableWidth) -> std::vector { std::vector wrappedLines; std::string currentLine; std::string word; const char *ptr = text; while (*ptr && wrappedLines.size() < maxContentLines) { // Skip whitespace and handle explicit newlines while (*ptr && (*ptr == ' ' || *ptr == '\t' || *ptr == '\n' || *ptr == '\r')) { if (*ptr == '\n') { if (!currentLine.empty()) { wrappedLines.push_back(currentLine); currentLine.clear(); if (wrappedLines.size() >= maxContentLines) break; } } ++ptr; } if (!*ptr || wrappedLines.size() >= maxContentLines) break; // Collect next word word.clear(); while (*ptr && *ptr != ' ' && *ptr != '\t' && *ptr != '\n' && *ptr != '\r') { word += *ptr++; } if (word.empty()) continue; // Try to append word std::string testLine = currentLine.empty() ? word : (currentLine + " " + word); uint16_t testWidth = display->getStringWidth(testLine.c_str(), testLine.length(), true); if (testWidth <= availableWidth) { currentLine = testLine; } else { if (!currentLine.empty()) { wrappedLines.push_back(currentLine); currentLine = word; if (wrappedLines.size() >= maxContentLines) break; } else { // Single word longer than available width: hard cut down to fit currentLine = word; while (currentLine.size() > 1 && display->getStringWidth(currentLine.c_str(), currentLine.length(), true) > availableWidth) { currentLine.pop_back(); } } } } if (!currentLine.empty() && wrappedLines.size() < maxContentLines) { wrappedLines.push_back(currentLine); } return wrappedLines; }; // Add title if present if (hasTitle) { allLines.emplace_back(keyboardPopupTitle); } // Split content by newlines first, then wrap each paragraph std::vector contentLines; char *paragraph = strtok(contentBuffer, "\n"); while (paragraph != nullptr && contentLines.size() < maxContentLines) { auto wrapped = wrapText(paragraph, maxWrapWidth); for (const auto &ln : wrapped) { if (contentLines.size() >= maxContentLines) break; contentLines.push_back(ln); } paragraph = strtok(nullptr, "\n"); } // Add content lines to allLines for (const auto &ln : contentLines) { allLines.push_back(ln); } // Convert to const char* array for drawNotificationBox std::vector linePointers; for (const auto &line : allLines) { linePointers.push_back(line.c_str()); } linePointers.push_back(nullptr); // null terminate // Use a custom inverted color version for keyboard popup drawInvertedNotificationBox(display, nullptr, linePointers.data(), allLines.size(), 0, 0); } // Custom inverted color version for keyboard popup - black background with white text void NotificationRenderer::drawInvertedNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[], uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth) { bool is_picker = false; uint16_t lineCount = 0; // === Layout Configuration === constexpr uint16_t hPadding = 5; constexpr uint16_t vPadding = 2; bool needs_bell = false; uint16_t lineWidths[totalLines] = {0}; uint16_t lineLengths[totalLines] = {0}; if (maxWidth != 0) is_picker = true; // Setup font and alignment display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); while (lines[lineCount] != nullptr) { auto newlinePointer = strchr(lines[lineCount], '\n'); if (newlinePointer) lineLengths[lineCount] = (newlinePointer - lines[lineCount]); else lineLengths[lineCount] = strlen(lines[lineCount]); lineWidths[lineCount] = display->getStringWidth(lines[lineCount], lineLengths[lineCount], true); if (!is_picker) { if (lineWidths[lineCount] > maxWidth) maxWidth = lineWidths[lineCount]; } lineCount++; } uint16_t boxWidth = hPadding * 2 + maxWidth; if (needs_bell) { if (isHighResolution && boxWidth <= 150) boxWidth += 26; if (!isHighResolution && boxWidth <= 100) boxWidth += 20; } uint16_t screenHeight = display->height(); uint8_t effectiveLineHeight = FONT_HEIGHT_SMALL - 3; uint8_t visibleTotalLines = std::min(lineCount, (screenHeight - vPadding * 2) / effectiveLineHeight); uint16_t contentHeight = visibleTotalLines * effectiveLineHeight; uint16_t boxHeight = contentHeight + vPadding * 2; if (visibleTotalLines == 1) { boxHeight += (isHighResolution) ? 4 : 3; } int16_t boxLeft = (display->width() / 2) - (boxWidth / 2); if (totalLines > visibleTotalLines) { boxWidth += (isHighResolution) ? 4 : 2; } int16_t boxTop = (display->height() / 2) - (boxHeight / 2); // === Draw Box with INVERTED COLORS === // Add outer separation pixels (1-pixel white background around the box) display->setColor(WHITE); display->fillRect(boxLeft - 1, boxTop - 1, boxWidth + 2, boxHeight + 2); // Make outer corners round by filling back with背景色 (BLACK for separation) display->setColor(BLACK); // Top-left outer corner display->fillRect(boxLeft - 1, boxTop - 1, 1, 1); // Top-right outer corner display->fillRect(boxLeft + boxWidth, boxTop - 1, 1, 1); // Bottom-left outer corner display->fillRect(boxLeft - 1, boxTop + boxHeight, 1, 1); // Bottom-right outer corner display->fillRect(boxLeft + boxWidth, boxTop + boxHeight, 1, 1); // Draw single pixel black border display->setColor(BLACK); display->drawRect(boxLeft, boxTop, boxWidth, boxHeight); // Make inner corners round by filling white pixels at corners display->setColor(WHITE); display->fillRect(boxLeft, boxTop, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop, 1, 1); display->fillRect(boxLeft, boxTop + boxHeight - 1, 1, 1); display->fillRect(boxLeft + boxWidth - 1, boxTop + boxHeight - 1, 1, 1); // Fill interior with BLACK (inverted) display->setColor(BLACK); display->fillRect(boxLeft + 1, boxTop + 1, boxWidth - 2, boxHeight - 2); // === Draw Content with WHITE text on BLACK background === display->setColor(WHITE); int16_t lineY = boxTop + vPadding; for (int i = 0; i < lineCount; i++) { int16_t textX = boxLeft + (boxWidth - lineWidths[i]) / 2; char lineBuffer[lineLengths[i] + 1]; strncpy(lineBuffer, lines[i], lineLengths[i]); lineBuffer[lineLengths[i]] = '\0'; // For keyboard popup, treat first line as title if it's different from others if (i == 0 && lineCount > 1) { // Title line - use inverted colors (white background, black text) display->setColor(WHITE); int background_yOffset = 1; // Check for low hanging characters if (strchr(lineBuffer, 'p') || strchr(lineBuffer, 'g') || strchr(lineBuffer, 'y') || strchr(lineBuffer, 'j')) { background_yOffset = -1; } display->fillRect(boxLeft + 1, boxTop + 1, boxWidth - 2, effectiveLineHeight - background_yOffset); display->setColor(BLACK); int yOffset = 3; display->drawString(textX, lineY - yOffset, lineBuffer); display->setColor(WHITE); // Reset to white for next lines lineY += (effectiveLineHeight - 2 - background_yOffset); } else { // Content lines - white text on black background display->drawString(textX, lineY, lineBuffer); lineY += effectiveLineHeight; } } // === Scroll Bar (if needed) === if (totalLines > visibleTotalLines) { const uint8_t scrollBarWidth = 5; int16_t scrollBarX = boxLeft + boxWidth - scrollBarWidth - 2; int16_t scrollBarY = boxTop + vPadding + effectiveLineHeight; uint16_t scrollBarHeight = boxHeight - vPadding * 2 - effectiveLineHeight; float ratio = (float)visibleTotalLines / totalLines; uint16_t indicatorHeight = std::max((int)(scrollBarHeight * ratio), 4); float scrollRatio = (float)(firstOptionToShow + lineCount - visibleTotalLines) / (totalLines - visibleTotalLines); uint16_t indicatorY = scrollBarY + scrollRatio * (scrollBarHeight - indicatorHeight); display->setColor(WHITE); display->drawRect(scrollBarX, scrollBarY, scrollBarWidth, scrollBarHeight); display->fillRect(scrollBarX + 1, indicatorY, scrollBarWidth - 2, indicatorHeight); } } } // namespace graphics #endif