From b32293f2cb46b57e8ef51684719d289062402536 Mon Sep 17 00:00:00 2001 From: whywilson Date: Fri, 5 Sep 2025 11:37:08 +0800 Subject: [PATCH] Show message popup over VirtualKeyboard. --- src/graphics/Screen.cpp | 94 +++++-- src/graphics/draw/NotificationRenderer.cpp | 293 +++++++++++++++++++++ src/graphics/draw/NotificationRenderer.h | 9 + src/modules/CannedMessageModule.cpp | 21 +- 4 files changed, 391 insertions(+), 26 deletions(-) diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 3e45bed45..25d59fe0e 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -149,6 +149,11 @@ void Screen::showSimpleBanner(const char *message, uint32_t durationMs) // Called to trigger a banner with custom message and duration void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) { + // Don't show overlay banner if virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif @@ -173,6 +178,11 @@ void Screen::showOverlayBanner(BannerOverlayOptions banner_overlay_options) // Called to trigger a banner with custom message and duration void Screen::showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback) { + // Don't show node picker if virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif @@ -196,6 +206,11 @@ void Screen::showNodePicker(const char *message, uint32_t durationMs, std::funct void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback) { + // Don't show number picker if virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + #ifdef USE_EINK EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Skip full refresh for all overlay menus #endif @@ -1400,21 +1415,28 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) // Incoming message devicestate.has_rx_text_message = true; // Needed to include the message frame hasUnreadMessage = true; // Enables mail icon in the header - setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view - // Only wake/force display if the configuration allows it - if (shouldWakeOnReceivedMessage()) { - setOn(true); // Wake up the screen first - forceDisplay(); // Forces screen redraw + // Always update frame list to include new message, but defer UI updates if virtual keyboard is active + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + setFrames(FOCUS_PRESERVE); // Refresh frame list without switching view + + // Only wake/force display if the configuration allows it + if (shouldWakeOnReceivedMessage()) { + setOn(true); // Wake up the screen first + forceDisplay(); // Forces screen redraw + } + } else { + // Virtual keyboard is active - just mark that frames need regeneration when keyboard closes + // The devicestate and hasUnreadMessage are already set above, so message will appear later + LOG_INFO("Virtual keyboard active - deferring frame list update for new message"); } - // === Prepare banner content === + + // Show message alert - either as normal banner or as keyboard popup + // === Common variables === const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(packet->from); const char *longName = (node && node->has_user) ? node->user.long_name : nullptr; - const char *msgRaw = reinterpret_cast(packet->decoded.payload.bytes); - char banner[256]; - // Check for bell character in message to determine alert type bool isAlert = false; for (size_t i = 0; i < packet->decoded.payload.size && i < 100; i++) { @@ -1424,21 +1446,57 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) } } - if (isAlert) { - if (longName && longName[0]) { - snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + // === Normal banner mode === + char banner[256]; + if (isAlert) { + if (longName && longName[0]) { + snprintf(banner, sizeof(banner), "Alert Received from\n%s", longName); + } else { + strcpy(banner, "Alert Received"); + } } else { - strcpy(banner, "Alert Received"); + if (longName && longName[0]) { + snprintf(banner, sizeof(banner), "New Message from\n%s", longName); + } else { + strcpy(banner, "New Message"); + } } + screen->showSimpleBanner(banner, 3000); } else { - if (longName && longName[0]) { - snprintf(banner, sizeof(banner), "New Message from\n%s", longName); + // === Virtual keyboard popup mode === + char title[64]; + if (isAlert) { + if (longName && longName[0]) { + snprintf(title, sizeof(title), "Alert from %s", longName); + } else { + strcpy(title, "Alert Received"); + } } else { - strcpy(banner, "New Message"); + if (longName && longName[0]) { + snprintf(title, sizeof(title), "%s", longName); + } else { + strcpy(title, "New Message"); + } } - } - screen->showSimpleBanner(banner, 3000); + // Prepare content - clean the message content + char content[200]; + size_t contentLen = 0; + for (size_t i = 0; i < packet->decoded.payload.size && i < sizeof(content) - 1; i++) { + if (msgRaw[i] != '\x07' && msgRaw[i] != '\0') { // Skip bell character and null + content[contentLen++] = msgRaw[i]; + } + } + content[contentLen] = '\0'; + + // Show popup with title and content on virtual keyboard + NotificationRenderer::showKeyboardMessagePopupWithTitle(title, content, 5000); + + // Force display update to show the popup immediately + setFastFramerate(); + forceDisplay(); + } } } diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index b53cd2f3f..3ed0f33dc 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -49,6 +49,12 @@ 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; @@ -85,9 +91,17 @@ void NotificationRenderer::drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiStat 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; @@ -100,6 +114,12 @@ void NotificationRenderer::resetBanner() 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) @@ -693,6 +713,9 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat 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"); @@ -705,5 +728,275 @@ 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 \ No newline at end of file diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index edb069513..20bbe6b0a 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -30,7 +30,14 @@ class NotificationRenderer static bool pauseBanner; + static char keyboardPopupMessage[256]; + static char keyboardPopupTitle[64]; + static uint32_t keyboardPopupUntil; + static bool showKeyboardPopup; + static void resetBanner(); + static void showKeyboardMessagePopupWithTitle(const char *title, const char *content, uint32_t durationMs); + static void drawKeyboardMessagePopup(OLEDDisplay *display); static void drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); @@ -38,6 +45,8 @@ class NotificationRenderer static void drawTextInput(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[MAX_LINES + 1], uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth = 0); + static void drawInvertedNotificationBox(OLEDDisplay *display, OLEDDisplayUiState *state, const char *lines[], + uint16_t totalLines, uint8_t firstOptionToShow, uint16_t maxWidth = 0); static void drawCriticalFaultFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); static void drawSSLScreen(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y); diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index e8c85235a..3a2189fad 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -653,11 +653,12 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; currentMessageIndex = -1; - UIFrameEvent e; - e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; - this->notifyObservers(&e); - screen->forceDisplay(); + // IMPORTANT: Don't delete virtual keyboard here - it's still executing! + // Just clear the callback to prevent further input + graphics::NotificationRenderer::textInputCallback = nullptr; + // Schedule keyboard cleanup for next run cycle + // This allows the current submitText() method to complete safely setIntervalFromNow(500); return; } else { @@ -1003,8 +1004,9 @@ int32_t CannedMessageModule::runOnce() // Normal module disable/idle handling if ((this->runState == CANNED_MESSAGE_RUN_STATE_DISABLED) || (this->runState == CANNED_MESSAGE_RUN_STATE_INACTIVE)) { - // Clean up virtual keyboard if needed when going inactive - if (graphics::NotificationRenderer::virtualKeyboard && graphics::NotificationRenderer::textInputCallback == nullptr) { + // Clean up virtual keyboard if needed when going inactive, but only if virtual keyboard is not actively being used + if (graphics::NotificationRenderer::virtualKeyboard && graphics::NotificationRenderer::textInputCallback == nullptr && + graphics::NotificationRenderer::current_notification_type != graphics::notificationTypeEnum::text_input) { LOG_INFO("Performing delayed virtual keyboard cleanup"); delete graphics::NotificationRenderer::virtualKeyboard; graphics::NotificationRenderer::virtualKeyboard = nullptr; @@ -1081,8 +1083,11 @@ int32_t CannedMessageModule::runOnce() this->cursor = 0; this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; - // Clean up virtual keyboard if it exists during timeout - if (graphics::NotificationRenderer::virtualKeyboard) { + // Clean up virtual keyboard if it exists during timeout, but only if it's not actively being used by another process + if (graphics::NotificationRenderer::virtualKeyboard && + graphics::NotificationRenderer::current_notification_type == graphics::notificationTypeEnum::text_input) { + LOG_INFO("Virtual keyboard is active - not cleaning up due to CannedMessage timeout"); + } else if (graphics::NotificationRenderer::virtualKeyboard) { LOG_INFO("Cleaning up virtual keyboard due to module timeout"); delete graphics::NotificationRenderer::virtualKeyboard; graphics::NotificationRenderer::virtualKeyboard = nullptr;