From 2fcb08a20ab8415e3e08f2803d1b69edc506d588 Mon Sep 17 00:00:00 2001 From: whywilson Date: Fri, 25 Jul 2025 16:54:06 +0800 Subject: [PATCH 1/6] Add virtural keyboard for wio tracker L1. --- src/buzz/BuzzerFeedbackThread.cpp | 1 + src/graphics/Screen.cpp | 75 ++- src/graphics/Screen.h | 4 +- src/graphics/VirtualKeyboard.cpp | 569 +++++++++++++++++++++ src/graphics/VirtualKeyboard.h | 85 +++ src/graphics/draw/MenuHandler.cpp | 3 + src/graphics/draw/NotificationRenderer.cpp | 108 +++- src/graphics/draw/NotificationRenderer.h | 6 + src/input/InputBroker.h | 1 + src/input/TrackballInterruptBase.cpp | 64 ++- src/input/TrackballInterruptBase.h | 11 +- src/input/TrackballInterruptImpl1.cpp | 7 +- src/modules/CannedMessageModule.cpp | 146 +++++- 13 files changed, 1053 insertions(+), 27 deletions(-) create mode 100644 src/graphics/VirtualKeyboard.cpp create mode 100644 src/graphics/VirtualKeyboard.h diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp index ce762c764..838224c69 100644 --- a/src/buzz/BuzzerFeedbackThread.cpp +++ b/src/buzz/BuzzerFeedbackThread.cpp @@ -28,6 +28,7 @@ int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event) case INPUT_BROKER_USER_PRESS: case INPUT_BROKER_ALT_PRESS: case INPUT_BROKER_SELECT: + case INPUT_BROKER_SELECT_LONG: playBeep(); // Confirmation feedback break; diff --git a/src/graphics/Screen.cpp b/src/graphics/Screen.cpp index 87d394d69..1243263c3 100644 --- a/src/graphics/Screen.cpp +++ b/src/graphics/Screen.cpp @@ -216,6 +216,44 @@ void Screen::showNumberPicker(const char *message, uint32_t durationMs, uint8_t ui->update(); } +void Screen::showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback) +{ + LOG_INFO("showTextInput called with header='%s', durationMs=%d", header ? header : "NULL", durationMs); + + if (NotificationRenderer::virtualKeyboard) { + delete NotificationRenderer::virtualKeyboard; + NotificationRenderer::virtualKeyboard = nullptr; + } + + NotificationRenderer::textInputCallback = nullptr; + + NotificationRenderer::virtualKeyboard = new VirtualKeyboard(); + if (header) { + NotificationRenderer::virtualKeyboard->setHeader(header); + } + if (initialText) { + NotificationRenderer::virtualKeyboard->setInputText(initialText); + } + + // Set up callback with safer cleanup mechanism + NotificationRenderer::textInputCallback = textCallback; + NotificationRenderer::virtualKeyboard->setCallback([textCallback](const std::string &text) { textCallback(text); }); + + // Store the message and set the expiration timestamp (use same pattern as other notifications) + strncpy(NotificationRenderer::alertBannerMessage, header ? header : "Text Input", 255); + NotificationRenderer::alertBannerMessage[255] = '\0'; + NotificationRenderer::alertBannerUntil = (durationMs == 0) ? 0 : millis() + durationMs; + NotificationRenderer::pauseBanner = false; + NotificationRenderer::current_notification_type = notificationTypeEnum::text_input; + + // Set the overlay using the same pattern as other notification types + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + ui->setTargetFPS(60); + ui->update(); +} + static void drawModuleFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) { uint8_t module_frame; @@ -706,13 +744,19 @@ int32_t Screen::runOnce() handleSetOn(false); break; case Cmd::ON_PRESS: - handleOnPress(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleOnPress(); + } break; case Cmd::SHOW_PREV_FRAME: - handleShowPrevFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowPrevFrame(); + } break; case Cmd::SHOW_NEXT_FRAME: - handleShowNextFrame(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + handleShowNextFrame(); + } break; case Cmd::START_ALERT_FRAME: { showingBootScreen = false; // this should avoid the edge case where an alert triggers before the boot screen goes away @@ -734,7 +778,9 @@ int32_t Screen::runOnce() NotificationRenderer::pauseBanner = false; case Cmd::STOP_BOOT_SCREEN: EINK_ADD_FRAMEFLAG(dispdev, COSMETIC); // E-Ink: Explicitly use full-refresh for next frame - setFrames(); + if (NotificationRenderer::current_notification_type != notificationTypeEnum::text_input) { + setFrames(); + } break; case Cmd::NOOP: break; @@ -770,6 +816,7 @@ int32_t Screen::runOnce() if (showingNormalScreen) { // standard screen loop handling here if (config.display.auto_screen_carousel_secs > 0 && + NotificationRenderer::current_notification_type != notificationTypeEnum::text_input && !Throttle::isWithinTimespanMs(lastScreenTransition, config.display.auto_screen_carousel_secs * 1000)) { // If an E-Ink display struggles with fast refresh, force carousel to use full refresh instead @@ -860,6 +907,11 @@ void Screen::setScreensaverFrames(FrameCallback einkScreensaver) // Called when a frame should be added / removed, or custom frames should be cleared void Screen::setFrames(FrameFocus focus) { + // Block setFrames calls when virtual keyboard is active to prevent overlay interference + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return; + } + uint8_t originalPosition = ui->getUiState()->currentFrame; uint8_t previousFrameCount = framesetInfo.frameCount; FramesetInfo fsi; // Location of specific frames, for applying focus parameter @@ -1307,6 +1359,11 @@ int Screen::handleTextMessage(const meshtastic_MeshPacket *packet) // Triggered by MeshModules int Screen::handleUIFrameEvent(const UIFrameEvent *event) { + // Block UI frame events when virtual keyboard is active + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + return 0; + } + if (showingNormalScreen) { // Regenerate the frameset, potentially honoring a module's internal requestFocus() call if (event->action == UIFrameEvent::Action::REGENERATE_FRAMESET) @@ -1329,6 +1386,16 @@ int Screen::handleInputEvent(const InputEvent *event) if (!screenOn) return 0; + // Handle text input notifications specially - pass input to virtual keyboard + if (NotificationRenderer::current_notification_type == notificationTypeEnum::text_input) { + NotificationRenderer::inEvent = *event; + static OverlayCallback overlays[] = {graphics::UIRenderer::drawNavigationBar, NotificationRenderer::drawBannercallback}; + ui->setOverlays(overlays, sizeof(overlays) / sizeof(overlays[0])); + setFastFramerate(); // Draw ASAP + ui->update(); + return 0; + } + #ifdef USE_EINK // the screen is the last input handler, so if an event makes it here, we can assume it will prompt a screen draw. EINK_ADD_FRAMEFLAG(dispdev, DEMAND_FAST); // Use fast-refresh for next frame, no skip please EINK_ADD_FRAMEFLAG(dispdev, BLOCKING); // Edge case: if this frame is promoted to COSMETIC, wait for update diff --git a/src/graphics/Screen.h b/src/graphics/Screen.h index 265900131..0f100d455 100644 --- a/src/graphics/Screen.h +++ b/src/graphics/Screen.h @@ -12,7 +12,7 @@ #define getStringCenteredX(s) ((SCREEN_WIDTH - display->getStringWidth(s)) / 2) namespace graphics { -enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker }; +enum notificationTypeEnum { none, text_banner, selection_picker, node_picker, number_picker, text_input }; struct BannerOverlayOptions { const char *message; @@ -313,6 +313,8 @@ class Screen : public concurrency::OSThread void showNodePicker(const char *message, uint32_t durationMs, std::function bannerCallback); void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function bannerCallback); + void showTextInput(const char *header, const char *initialText, uint32_t durationMs, + std::function textCallback); void requestMenu(graphics::menuHandler::screenMenus menuToShow) { diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp new file mode 100644 index 000000000..521cad360 --- /dev/null +++ b/src/graphics/VirtualKeyboard.cpp @@ -0,0 +1,569 @@ +#include "VirtualKeyboard.h" +#include "configuration.h" +#include "graphics/Screen.h" +#include "graphics/SharedUIDisplay.h" +#include "main.h" +#include + +namespace graphics +{ + +VirtualKeyboard::VirtualKeyboard() + : cursorRow(0), cursorCol(0), closeButtonX(0), closeButtonY(0), closeButtonWidth(0), closeButtonHeight(0), + cursorOnCloseButton(false), lastActivityTime(millis()) +{ + initializeKeyboard(); + // Set cursor to Q(0, 0) + cursorRow = 0; + cursorCol = 0; +} + +VirtualKeyboard::~VirtualKeyboard() {} + +void VirtualKeyboard::initializeKeyboard() +{ + // Initialize all keys to empty first + for (int row = 0; row < KEYBOARD_ROWS; row++) { + for (int col = 0; col < KEYBOARD_COLS; col++) { + keyboard[row][col] = {0, VK_CHAR, 0, 0, 0, 0}; + } + } + + // Row 0: q w e r t y u i o p 0 1 2 3 + const char *row0 = "qwertyuiop0123"; + for (int i = 0; i < 14; i++) { + keyboard[0][i] = {row0[i], VK_CHAR, (uint8_t)(i * KEY_WIDTH), 0, KEY_WIDTH, KEY_HEIGHT}; + } + + // Row 1: a s d f g h j k l ← 4 5 6 + const char *row1 = "asdfghjkl"; + for (int i = 0; i < 9; i++) { + keyboard[1][i] = {row1[i], VK_CHAR, (uint8_t)(i * KEY_WIDTH), KEY_HEIGHT, KEY_WIDTH, KEY_HEIGHT}; + } + // Backspace key (2 chars wide) + keyboard[1][9] = {'\b', VK_BACKSPACE, 9 * KEY_WIDTH, KEY_HEIGHT, KEY_WIDTH * 2, KEY_HEIGHT}; + // Numbers 4, 5, 6 + const char *numbers456 = "456"; + for (int i = 0; i < 3; i++) { + keyboard[1][11 + i] = {numbers456[i], VK_CHAR, (uint8_t)((11 + i) * KEY_WIDTH), KEY_HEIGHT, KEY_WIDTH, KEY_HEIGHT}; + } + + // Row 2: z x c v b n m _ . OK 7 8 9 + const char *row2 = "zxcvbnm_."; + for (int i = 0; i < 9; i++) { + keyboard[2][i] = {row2[i], VK_CHAR, (uint8_t)(i * KEY_WIDTH), 2 * KEY_HEIGHT, KEY_WIDTH, KEY_HEIGHT}; + } + // OK key (Enter) - 2 chars wide + keyboard[2][9] = {'\n', VK_ENTER, 9 * KEY_WIDTH, 2 * KEY_HEIGHT, KEY_WIDTH * 2, KEY_HEIGHT}; + // Numbers 7, 8, 9 + const char *numbers789 = "789"; + for (int i = 0; i < 3; i++) { + keyboard[2][11 + i] = {numbers789[i], VK_CHAR, (uint8_t)((11 + i) * KEY_WIDTH), 2 * KEY_HEIGHT, KEY_WIDTH, KEY_HEIGHT}; + } +} + +void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY) +{ + // Set initial color and font + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + // Draw input area (header + input box) + drawInputArea(display, offsetX, offsetY); + + // Draw keyboard with proper QWERTY layout + for (int row = 0; row < KEYBOARD_ROWS; row++) { + for (int col = 0; col < KEYBOARD_COLS; col++) { + if (keyboard[row][col].character != 0 || keyboard[row][col].type != VK_CHAR) { // Include special keys + bool selected = (row == cursorRow && col == cursorCol && !cursorOnCloseButton); + drawKey(display, keyboard[row][col], selected, offsetX, offsetY + KEYBOARD_START_Y); + } + } + } + + drawCloseButton(display, offsetX, offsetY, cursorOnCloseButton); +} + +void VirtualKeyboard::drawCloseButton(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, bool selected) +{ + if (closeButtonX == 0 && closeButtonY == 0) { + // Close button position not set yet + return; + } + + display->setColor(WHITE); + + if (selected) { + // Draw highlighted close button background + display->drawRect(closeButtonX - 1, closeButtonY - 1, closeButtonWidth + 2, closeButtonHeight + 2); + display->fillRect(closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight); + display->setColor(BLACK); + } + + // Draw the X symbol + display->drawLine(closeButtonX, closeButtonY, closeButtonX + closeButtonWidth, closeButtonY + closeButtonHeight); + display->drawLine(closeButtonX + closeButtonWidth, closeButtonY, closeButtonX, closeButtonY + closeButtonHeight); + + // Reset color + display->setColor(WHITE); +} + +void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY) +{ + display->setColor(WHITE); + display->setFont(FONT_SMALL); + + int screenWidth = display->getWidth(); + + int headerHeight = 0; + if (!headerText.empty()) { + display->drawString(offsetX + 2, offsetY, headerText.c_str()); + + // Set close button position + closeButtonX = screenWidth - 12; + closeButtonY = offsetY; + closeButtonWidth = 8; + closeButtonHeight = 8; + + drawCloseButton(display, offsetX, offsetY, false); + + headerHeight = 10; + } + + // Draw input box - positioned just below header + int boxWidth = screenWidth - 4; + int boxY = offsetY + headerHeight + 2; + int boxHeight = 14; // Increased by 2 pixels + + // Draw box border + display->drawRect(offsetX + 2, boxY, boxWidth, boxHeight); + + // Prepare display text + std::string displayText = inputText; + if (displayText.empty()) { + displayText = ""; // Don't show placeholder when empty + } + + // Handle text overflow with scrolling + int textPadding = 4; + int maxWidth = boxWidth - textPadding; + int textWidth = display->getStringWidth(displayText.c_str()); + + std::string scrolledText = displayText; + if (textWidth > maxWidth) { + // Scroll text to show the end (cursor position) + while (textWidth > maxWidth && !scrolledText.empty()) { + scrolledText = scrolledText.substr(1); + textWidth = display->getStringWidth(scrolledText.c_str()); + } + if (scrolledText != displayText) { + scrolledText = "..." + scrolledText; + } + } + + // Draw text inside the box - properly centered vertically in the input box + int textX = offsetX + 4; + int textY = boxY - 1; // Moved down by 1 pixel + + if (!scrolledText.empty()) { + display->drawString(textX, textY, scrolledText.c_str()); + } + + // Draw cursor at the end of visible text - aligned with text baseline + if (!inputText.empty() || true) { // Always show cursor for visibility + int cursorX = textX + display->getStringWidth(scrolledText.c_str()); + // Ensure cursor stays within box bounds + if (cursorX < offsetX + boxWidth - 2) { + // Align cursor properly with the text baseline and height - moved down by 2 pixels + display->drawVerticalLine(cursorX, textY + 3, 10); + } + } +} + +void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t offsetX, int16_t offsetY) +{ + int x = offsetX + key.x; + int y = offsetY + key.y; + + // Draw border for OK key or selected keys (NOT for backspace key) + bool drawBorder = selected || (key.type == VK_ENTER); + + if (drawBorder) { + if (selected) { + if (key.type == VK_BACKSPACE) { + display->fillRect(x, y + 3, key.width, 10); + } else if (key.type == VK_ENTER) { + display->fillRect(x, y + 3, key.width, 10); + } else { + display->fillRect(x, y + 3, key.width, key.height); + } + display->setColor(BLACK); + } else { + display->setColor(WHITE); + } + } else { + display->setColor(WHITE); + } + + // Draw key content + display->setFont(FONT_SMALL); + + if (key.type == VK_BACKSPACE) { + int centerX = x + key.width / 2; + int centerY = y + key.height / 2; + + display->drawLine(centerX - 3, centerY + 1, centerX + 2, centerY + 1); // horizontal line + display->drawLine(centerX - 3, centerY + 1, centerX - 1, centerY - 1); // upper diagonal + display->drawLine(centerX - 3, centerY + 1, centerX - 1, centerY + 3); // lower diagonal + } else if (key.type == VK_ENTER) { + std::string keyText = "OK"; + int textWidth = display->getStringWidth(keyText.c_str()); + int textX = x + (key.width - textWidth) / 2; + int textY = y + 2; + display->drawString(textX, textY - 1, keyText.c_str()); + display->drawRect(textX - 1, textY, textWidth + 3, 11); + } else { + std::string keyText; + char c = getCharForKey(key, false); // Pass false for display purposes + + if (key.character == ' ') { + keyText = "_"; // Show underscore for space + } else if (key.character == '_') { + keyText = "_"; // Show underscore for underscore character + } else { + keyText = c; + } + + // Center text in key with perfect horizontal and vertical alignment + int textWidth = display->getStringWidth(keyText.c_str()); + int textX = x + (key.width - textWidth) / 2; + int textY = y; // Fixed position for optimal centering in 12px height + + // If the character is a digit, adjust X position by +1 + if (key.character >= '0' && key.character <= '9') { + textX += 1; + textY += 1; + } + + display->drawString(textX, textY + 1, keyText.c_str()); + } + + // Reset color after drawing + if (selected) { + display->setColor(WHITE); + } +} + +char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress) +{ + if (key.type != VK_CHAR) { + return key.character; + } + + char c = key.character; + + if (isLongPress) { + if (c == '_') { + return ' '; + } else if (c == '.') { + return ','; + } else if (c >= 'a' && c <= 'z') { + c = c - 'a' + 'A'; + } + } + + return c; +} + +void VirtualKeyboard::moveCursorUp() +{ + resetTimeout(); // Reset timeout on any input activity + + // If we're on the close button, move to keyboard + if (cursorOnCloseButton) { + cursorOnCloseButton = false; + cursorRow = 0; + cursorCol = KEYBOARD_COLS - 1; // Move to rightmost key in top row + return; + } + + uint8_t originalRow = cursorRow; + if (cursorRow > 0) { + cursorRow--; + } else { + // From top row, move to close button if on rightmost position + if (cursorCol >= KEYBOARD_COLS - 3) { // Close to right edge + cursorOnCloseButton = true; + return; + } + cursorRow = KEYBOARD_ROWS - 1; + } + + // If the new position is empty, find the nearest valid key in this row + if (keyboard[cursorRow][cursorCol].character == 0) { + // First try to move left to find a valid key + uint8_t originalCol = cursorCol; + while (cursorCol > 0 && keyboard[cursorRow][cursorCol].character == 0) { + cursorCol--; + } + // If we still don't have a valid key, try moving right from original position + if (keyboard[cursorRow][cursorCol].character == 0) { + cursorCol = originalCol; + while (cursorCol < KEYBOARD_COLS - 1 && keyboard[cursorRow][cursorCol].character == 0) { + cursorCol++; + } + } + // If still no valid key, go back to original row + if (keyboard[cursorRow][cursorCol].character == 0) { + cursorRow = originalRow; + } + } +} + +void VirtualKeyboard::moveCursorDown() +{ + resetTimeout(); // Reset timeout on any input activity + + uint8_t originalRow = cursorRow; + if (cursorRow < KEYBOARD_ROWS - 1) { + cursorRow++; + } else { + cursorRow = 0; + } + + // If the new position is empty, find the nearest valid key in this row + if (keyboard[cursorRow][cursorCol].character == 0) { + // First try to move left to find a valid key + uint8_t originalCol = cursorCol; + while (cursorCol > 0 && keyboard[cursorRow][cursorCol].character == 0) { + cursorCol--; + } + // If we still don't have a valid key, try moving right from original position + if (keyboard[cursorRow][cursorCol].character == 0) { + cursorCol = originalCol; + while (cursorCol < KEYBOARD_COLS - 1 && keyboard[cursorRow][cursorCol].character == 0) { + cursorCol++; + } + } + // If still no valid key, go back to original row + if (keyboard[cursorRow][cursorCol].character == 0) { + cursorRow = originalRow; + } + } +} + +void VirtualKeyboard::moveCursorLeft() +{ + resetTimeout(); // Reset timeout on any input activity + + // Find the previous valid key position + do { + if (cursorCol > 0) { + cursorCol--; + } else { + if (cursorRow > 0) { + cursorRow--; + cursorCol = KEYBOARD_COLS - 1; + } else { + cursorRow = KEYBOARD_ROWS - 1; + cursorCol = KEYBOARD_COLS - 1; + } + } + } while ((keyboard[cursorRow][cursorCol].character == 0 && keyboard[cursorRow][cursorCol].type == VK_CHAR) && + !(cursorRow == 0 && cursorCol == 0)); // Prevent infinite loop +} + +void VirtualKeyboard::moveCursorRight() +{ + resetTimeout(); // Reset timeout on any input activity + + // If we're on the close button, go back to keyboard + if (cursorOnCloseButton) { + cursorOnCloseButton = false; + cursorRow = 0; + cursorCol = 0; + return; + } + + // Find the next valid key position + do { + if (cursorCol < KEYBOARD_COLS - 1) { + cursorCol++; + } else { + // From top row's rightmost position, check if we should go to close button + if (cursorRow == 0) { + cursorOnCloseButton = true; + return; + } + + if (cursorRow < KEYBOARD_ROWS - 1) { + cursorRow++; + cursorCol = 0; + } else { + cursorRow = 0; + cursorCol = 0; + } + } + } while ((keyboard[cursorRow][cursorCol].character == 0 && keyboard[cursorRow][cursorCol].type == VK_CHAR) && + !(cursorRow == 0 && cursorCol == 0)); // Prevent infinite loop +} + +void VirtualKeyboard::handlePress() +{ + resetTimeout(); // Reset timeout on any input activity + + // Handle close button press + if (cursorOnCloseButton) { + LOG_INFO("Virtual keyboard: close button pressed, cancelling"); + if (onTextEntered) { + // Store callback before clearing to prevent use-after-free + std::function callback = onTextEntered; + onTextEntered = nullptr; // Clear immediately to prevent re-entry + inputText = ""; // Clear input + + // Call callback with empty string to signal cancellation + callback(""); + } + return; + } + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert lowercase character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, false)); // false = lowercase/normal char + return; + } + + // Handle non-character keys immediately + switch (key.type) { + case VK_BACKSPACE: + deleteCharacter(); + break; + case VK_ENTER: + submitText(); + break; + default: + break; + } +} + +void VirtualKeyboard::handleLongPress() +{ + resetTimeout(); // Reset timeout on any input activity + + // Handle close button long press (same as regular press for now) + if (cursorOnCloseButton) { + // Call callback with empty string to indicate cancel/close + if (onTextEntered) { + onTextEntered(""); + } + return; + } + + const VirtualKey &key = keyboard[cursorRow][cursorCol]; + + // Don't handle press if the key is empty (but allow special keys) + if (key.character == 0 && key.type == VK_CHAR) { + return; + } + + // For character keys, insert uppercase/alternate character + if (key.type == VK_CHAR) { + insertCharacter(getCharForKey(key, true)); // true = uppercase/alternate char + return; + } + + // For non-character keys, long press behaves the same as regular press + switch (key.type) { + case VK_BACKSPACE: + deleteCharacter(); + break; + case VK_ENTER: + submitText(); + break; + default: + break; + } +} + +void VirtualKeyboard::insertCharacter(char c) +{ + if (inputText.length() < 160) { // Reasonable text length limit + inputText += c; + } +} + +void VirtualKeyboard::deleteCharacter() +{ + if (!inputText.empty()) { + inputText.pop_back(); + } +} + +void VirtualKeyboard::submitText() +{ + LOG_INFO("Virtual keyboard: submitting text '%s'", inputText.c_str()); + + // Only submit if text is not empty + if (!inputText.empty() && onTextEntered) { + // Store callback and text to submit before clearing callback + std::function callback = onTextEntered; + std::string textToSubmit = inputText; + onTextEntered = nullptr; + // Don't clear inputText here - let the calling module handle cleanup + // inputText = ""; // Removed: keep text visible until module cleans up + callback(textToSubmit); + } else if (inputText.empty()) { + // For empty text, just ignore the submission - don't clear callback + // This keeps the virtual keyboard responsive for further input + LOG_INFO("Virtual keyboard: empty text submitted, ignoring - keyboard remains active"); + } else { + // No callback available + if (screen) { + screen->setFrames(graphics::Screen::FOCUS_PRESERVE); + } + } +} + +void VirtualKeyboard::setInputText(const std::string &text) +{ + inputText = text; +} + +std::string VirtualKeyboard::getInputText() const +{ + return inputText; +} + +void VirtualKeyboard::setHeader(const std::string &header) +{ + headerText = header; +} + +void VirtualKeyboard::setCallback(std::function callback) +{ + onTextEntered = callback; +} + +void VirtualKeyboard::resetTimeout() +{ + lastActivityTime = millis(); +} + +bool VirtualKeyboard::isTimedOut() const +{ + return (millis() - lastActivityTime) > TIMEOUT_MS; +} + +bool VirtualKeyboard::isCursorOnCloseButton() const +{ + return cursorOnCloseButton; +} + +} // namespace graphics diff --git a/src/graphics/VirtualKeyboard.h b/src/graphics/VirtualKeyboard.h new file mode 100644 index 000000000..b491cf1d9 --- /dev/null +++ b/src/graphics/VirtualKeyboard.h @@ -0,0 +1,85 @@ +#pragma once + +#include "configuration.h" +#include +#include +#include + +namespace graphics +{ + +enum VirtualKeyType { VK_CHAR, VK_BACKSPACE, VK_ENTER, VK_SHIFT }; + +struct VirtualKey { + char character; + VirtualKeyType type; + uint8_t x; + uint8_t y; + uint8_t width; + uint8_t height; +}; + +class VirtualKeyboard +{ + public: + VirtualKeyboard(); + ~VirtualKeyboard(); + + void draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); + void setInputText(const std::string &text); + std::string getInputText() const; + void setHeader(const std::string &header); + void setCallback(std::function callback); + + // Navigation methods for encoder input + void moveCursorUp(); + void moveCursorDown(); + void moveCursorLeft(); + void moveCursorRight(); + void handlePress(); + void handleLongPress(); + + // Timeout management + void resetTimeout(); + bool isTimedOut() const; + + // Check cursor position for input handling + bool isCursorOnCloseButton() const; + + private: + static const uint8_t KEYBOARD_ROWS = 3; + static const uint8_t KEYBOARD_COLS = 14; + static const uint8_t KEY_WIDTH = 9; + static const uint8_t KEY_HEIGHT = 12; // Optimized for FONT_SMALL text with minimal padding + static const uint8_t KEYBOARD_START_Y = 25; + + VirtualKey keyboard[KEYBOARD_ROWS][KEYBOARD_COLS]; + + std::string inputText; + std::string headerText; + std::function onTextEntered; + + uint8_t cursorRow; + uint8_t cursorCol; + + // Close button position for cursor navigation + int closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight; + bool cursorOnCloseButton; + + // Timeout management for auto-exit + uint32_t lastActivityTime; + static const uint32_t TIMEOUT_MS = 60000; // 1 minute timeout + + void initializeKeyboard(); + void drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t offsetX, int16_t offsetY); + void drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); + void drawCloseButton(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, bool selected); + void drawCursor(OLEDDisplay *display, int16_t offsetX, int16_t offsetY); + + char getCharForKey(const VirtualKey &key, bool isLongPress = false); + void insertCharacter(char c); + void deleteCharacter(); + void submitText(); +}; + +} // namespace graphics diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 83198a7c5..013191387 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -10,7 +10,10 @@ #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" #include "graphics/draw/UIRenderer.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" #include "main.h" +#include "mesh/MeshTypes.h" #include "modules/AdminModule.h" #include "modules/CannedMessageModule.h" #include "modules/KeyVerificationModule.h" diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index d9cf280ac..389baa82b 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -38,6 +38,8 @@ bool NotificationRenderer::pauseBanner = false; notificationTypeEnum NotificationRenderer::current_notification_type = notificationTypeEnum::none; 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) { @@ -89,10 +91,26 @@ void NotificationRenderer::resetBanner() void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayUiState *state) { - if (!isOverlayBannerShowing() && alertBannerMessage[0] != '\0') - resetBanner(); - if (!isOverlayBannerShowing() || pauseBanner) + // 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 @@ -570,6 +588,90 @@ void NotificationRenderer::drawFrameFirmware(OLEDDisplay *display, OLEDDisplayUi "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; + } + + // Handle input events for virtual keyboard navigation + if (inEvent.inputEvent != INPUT_BROKER_NONE) { + if (inEvent.inputEvent == INPUT_BROKER_UP) { + virtualKeyboard->moveCursorUp(); + } else if (inEvent.inputEvent == INPUT_BROKER_DOWN) { + 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_ALT_PRESS) { + // Long press UP = move left + virtualKeyboard->moveCursorLeft(); + } else if (inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { + // Long press DOWN = move right + 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) { + // Cancel virtual keyboard - call callback with empty string + 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; + } + + // Reset input event after processing + inEvent.inputEvent = INPUT_BROKER_NONE; + } + + // Clear the display and draw virtual keyboard + display->setColor(BLACK); + display->fillRect(0, 0, display->getWidth(), display->getHeight()); + display->setColor(WHITE); + virtualKeyboard->draw(display, 0, 0); + } else { + // If virtualKeyboard is null, reset the banner to avoid getting stuck + resetBanner(); + } +} + bool NotificationRenderer::isOverlayBannerShowing() { return strlen(alertBannerMessage) > 0 && (alertBannerUntil == 0 || millis() <= alertBannerUntil); diff --git a/src/graphics/draw/NotificationRenderer.h b/src/graphics/draw/NotificationRenderer.h index 9c30b329c..edb069513 100644 --- a/src/graphics/draw/NotificationRenderer.h +++ b/src/graphics/draw/NotificationRenderer.h @@ -3,6 +3,9 @@ #include "OLEDDisplay.h" #include "OLEDDisplayUi.h" #include "graphics/Screen.h" +#include "graphics/VirtualKeyboard.h" +#include +#include #define MAX_LINES 5 namespace graphics @@ -22,6 +25,8 @@ class NotificationRenderer static std::function alertBannerCallback; static uint32_t numDigits; static uint32_t currentNumber; + static VirtualKeyboard *virtualKeyboard; + static std::function textInputCallback; static bool pauseBanner; @@ -30,6 +35,7 @@ class NotificationRenderer static void drawAlertBannerOverlay(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNumberPicker(OLEDDisplay *display, OLEDDisplayUiState *state); static void drawNodePicker(OLEDDisplay *display, OLEDDisplayUiState *state); + 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); diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 4487fa662..012a403f5 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -4,6 +4,7 @@ enum input_broker_event { INPUT_BROKER_NONE = 0, INPUT_BROKER_SELECT = 10, + INPUT_BROKER_SELECT_LONG, INPUT_BROKER_UP = 17, INPUT_BROKER_DOWN = 18, INPUT_BROKER_LEFT = 19, diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index d41ad2fd6..e84e32705 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -5,8 +5,9 @@ TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency:: void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, - input_broker_event eventRight, input_broker_event eventPressed, void (*onIntDown)(), - void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) + input_broker_event eventRight, input_broker_event eventPressed, + input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()) { this->_pinDown = pinDown; this->_pinUp = pinUp; @@ -18,6 +19,7 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef this->_eventLeft = eventLeft; this->_eventRight = eventRight; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; if (pinPress != 255) { pinMode(pinPress, INPUT_PULLUP); @@ -50,10 +52,42 @@ int32_t TrackballInterruptBase::runOnce() { InputEvent e; e.inputEvent = INPUT_BROKER_NONE; + + // Handle long press detection for press button + if (pressDetected && pressStartTime > 0) { + uint32_t pressDuration = millis() - pressStartTime; + bool buttonStillPressed = false; + +#if defined(T_DECK) + buttonStillPressed = (this->action == TB_ACTION_PRESSED); +#else + buttonStillPressed = !digitalRead(_pinPress); +#endif + + if (!buttonStillPressed) { + // Button released + if (pressDuration < LONG_PRESS_DURATION) { + // Short press + e.inputEvent = this->_eventPressed; + } + // Reset state + pressDetected = false; + pressStartTime = 0; + this->action = TB_ACTION_NONE; + } else if (pressDuration >= LONG_PRESS_DURATION) { + // Long press detected + e.inputEvent = this->_eventPressedLong; + this->action = TB_ACTION_PRESSED_LONG; + // Keep pressDetected true to avoid repeated long press events + } + } + #if defined(T_DECK) // T-deck gets a super-simple debounce on trackball - if (this->action == TB_ACTION_PRESSED) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && lastEvent == TB_ACTION_UP) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -68,9 +102,11 @@ int32_t TrackballInterruptBase::runOnce() e.inputEvent = this->_eventRight; } #else - if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress)) { - // LOG_DEBUG("Trackball event Press"); - e.inputEvent = this->_eventPressed; + if (this->action == TB_ACTION_PRESSED && !digitalRead(_pinPress) && !pressDetected) { + // Start long press detection + pressDetected = true; + pressStartTime = millis(); + // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp)) { // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; @@ -91,10 +127,16 @@ int32_t TrackballInterruptBase::runOnce() e.kbchar = 0x00; this->notifyObservers(&e); } - lastEvent = action; - this->action = TB_ACTION_NONE; - return 100; + // Only update lastEvent for non-press actions or completed press actions + if (this->action != TB_ACTION_PRESSED || !pressDetected) { + lastEvent = action; + if (!pressDetected) { + this->action = TB_ACTION_NONE; + } + } + + return 50; // Check more frequently for better long press detection } void TrackballInterruptBase::intPressHandler() diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 92db8720e..30967fe7f 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -18,8 +18,8 @@ class TrackballInterruptBase : public Observable, public con explicit TrackballInterruptBase(const char *name); void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLeft, uint8_t pinRight, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, input_broker_event eventLeft, input_broker_event eventRight, - input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntLeft)(), void (*onIntRight)(), - void (*onIntPress)()); + input_broker_event eventPressed, input_broker_event eventPressedLong, void (*onIntDown)(), void (*onIntUp)(), + void (*onIntLeft)(), void (*onIntRight)(), void (*onIntPress)()); void intPressHandler(); void intDownHandler(); void intUpHandler(); @@ -33,6 +33,7 @@ class TrackballInterruptBase : public Observable, public con enum TrackballInterruptBaseActionType { TB_ACTION_NONE, TB_ACTION_PRESSED, + TB_ACTION_PRESSED_LONG, TB_ACTION_UP, TB_ACTION_DOWN, TB_ACTION_LEFT, @@ -46,12 +47,18 @@ class TrackballInterruptBase : public Observable, public con volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE; + // Long press detection for press button + uint32_t pressStartTime = 0; + bool pressDetected = false; + static const uint32_t LONG_PRESS_DURATION = 500; // ms + private: input_broker_event _eventDown = INPUT_BROKER_NONE; input_broker_event _eventUp = INPUT_BROKER_NONE; input_broker_event _eventLeft = INPUT_BROKER_NONE; input_broker_event _eventRight = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; const char *_originName; TrackballInterruptBaseActionType lastEvent = TB_ACTION_NONE; }; diff --git a/src/input/TrackballInterruptImpl1.cpp b/src/input/TrackballInterruptImpl1.cpp index 896238f38..594facdeb 100644 --- a/src/input/TrackballInterruptImpl1.cpp +++ b/src/input/TrackballInterruptImpl1.cpp @@ -13,11 +13,12 @@ void TrackballInterruptImpl1::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLe input_broker_event eventLeft = INPUT_BROKER_LEFT; input_broker_event eventRight = INPUT_BROKER_RIGHT; input_broker_event eventPressed = INPUT_BROKER_SELECT; + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; TrackballInterruptBase::init(pinDown, pinUp, pinLeft, pinRight, pinPress, eventDown, eventUp, eventLeft, eventRight, - eventPressed, TrackballInterruptImpl1::handleIntDown, TrackballInterruptImpl1::handleIntUp, - TrackballInterruptImpl1::handleIntLeft, TrackballInterruptImpl1::handleIntRight, - TrackballInterruptImpl1::handleIntPressed); + eventPressed, eventPressedLong, TrackballInterruptImpl1::handleIntDown, + TrackballInterruptImpl1::handleIntUp, TrackballInterruptImpl1::handleIntLeft, + TrackballInterruptImpl1::handleIntRight, TrackballInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); } diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 2a4f1cf4d..76db1066a 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -13,12 +13,16 @@ #include "detect/ScanI2C.h" #include "graphics/Screen.h" #include "graphics/SharedUIDisplay.h" +#include "graphics/draw/NotificationRenderer.h" #include "graphics/emotes.h" #include "graphics/images.h" #include "main.h" // for cardkb_found #include "mesh/generated/meshtastic/cannedmessages.pb.h" #include "modules/AdminModule.h" #include "modules/ExternalNotificationModule.h" // for buzzer control +#if HAS_TRACKBALL +#include "input/TrackballInterruptImpl1.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "GPS.h" #endif @@ -122,8 +126,20 @@ int CannedMessageModule::splitConfiguredMessages() tempMessages[tempCount++] = "[Select Destination]"; #if defined(USE_VIRTUAL_KEYBOARD) - // Add a "Free Text" entry at the top if using a keyboard + // Add a "Free Text" entry at the top if using a touch screen virtual keyboard tempMessages[tempCount++] = "[-- Free Text --]"; +#else + // For devices with encoder input or trackball, also add Free Text option +#if HAS_TRACKBALL + extern TrackballInterruptImpl1 *trackballInterruptImpl1; + if (trackballInterruptImpl1) { +#else + extern RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; + extern UpDownInterruptImpl1 *upDownInterruptImpl1; + if (rotaryEncoderInterruptImpl1 || upDownInterruptImpl1) { +#endif + tempMessages[tempCount++] = "[-- Free Text --]"; + } #endif // First message always starts at buffer start @@ -310,6 +326,8 @@ int CannedMessageModule::handleInputEvent(const InputEvent *event) case CANNED_MESSAGE_RUN_STATE_FREETEXT: return handleFreeTextInput(event); // All allowed input for this state + // Virtual keyboard mode: Show virtual keyboard and handle input + // If sending, block all input except global/system (handled above) case CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE: return 1; @@ -589,6 +607,60 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo notifyObservers(&e); return true; } +#else + if (strcmp(current, "[-- Free Text --]") == 0) { +#if HAS_TRACKBALL + extern TrackballInterruptImpl1 *trackballInterruptImpl1; + if (trackballInterruptImpl1 && screen) { +#else + extern RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; + extern UpDownInterruptImpl1 *upDownInterruptImpl1; + if ((rotaryEncoderInterruptImpl1 || upDownInterruptImpl1) && screen) { +#endif + screen->showTextInput("Free Text", "", 300000, [this](const std::string &text) { + LOG_INFO("Free text submitted: '%s'", text.c_str()); + if (!text.empty()) { + LOG_INFO("Storing message for delayed sending: '%s'", text.c_str()); + this->freetext = text.c_str(); + this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; + runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; + currentMessageIndex = -1; + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + setIntervalFromNow(500); + LOG_INFO("Free text callback completed safely"); + return; + } else { + // Don't delete virtual keyboard immediately - it might still be executing + // Instead, just clear the callback and reset banner to stop input processing + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + + // Return to inactive state + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + + // Force display update to show normal screen + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->notifyObservers(&e); + screen->forceDisplay(); + + // Schedule cleanup for next loop iteration to ensure safe deletion + setIntervalFromNow(50); + return; + } + }); + + return true; + } + } #endif // Normal canned message selection @@ -883,12 +955,54 @@ 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) { + LOG_INFO("Performing delayed virtual keyboard cleanup"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + } + temporaryMessage = ""; return INT32_MAX; } + // Handle delayed virtual keyboard message sending + if (this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + // Virtual keyboard message sending case - text was not empty + if (this->freetext.length() > 0) { + LOG_INFO("Processing delayed virtual keyboard send: '%s'", this->freetext.c_str()); + sendText(this->dest, this->channel, this->freetext.c_str(), true); + + // Clean up virtual keyboard after sending + if (graphics::NotificationRenderer::virtualKeyboard) { + LOG_INFO("Cleaning up virtual keyboard after message send"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + } + + // Clear payload to indicate virtual keyboard processing is complete + // But keep SENDING_ACTIVE state to show "Sending..." screen for 2 seconds + this->payload = 0; + } else { + // Empty message, just go inactive + LOG_INFO("Empty freetext detected in delayed processing, returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + } + + UIFrameEvent e; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + this->notifyObservers(&e); + return 2000; + } + UIFrameEvent e; - if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE) || + if ((this->runState == CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE && this->payload != 0 && + this->payload != CANNED_MESSAGE_RUN_STATE_FREETEXT) || (this->runState == CANNED_MESSAGE_RUN_STATE_ACK_NACK_RECEIVED) || (this->runState == CANNED_MESSAGE_RUN_STATE_MESSAGE_SELECTION)) { this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; @@ -898,6 +1012,18 @@ int32_t CannedMessageModule::runOnce() this->freetext = ""; this->cursor = 0; 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 happens after virtual keyboard message sending is complete + LOG_INFO("Virtual keyboard message sending completed, returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + temporaryMessage = ""; + e.action = UIFrameEvent::Action::REGENERATE_FRAMESET; + this->currentMessageIndex = -1; + this->freetext = ""; + this->cursor = 0; + this->notifyObservers(&e); } 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 @@ -906,9 +1032,23 @@ int32_t CannedMessageModule::runOnce() this->freetext = ""; this->cursor = 0; this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + + // Clean up virtual keyboard if it exists during timeout + if (graphics::NotificationRenderer::virtualKeyboard) { + LOG_INFO("Cleaning up virtual keyboard due to module timeout"); + delete graphics::NotificationRenderer::virtualKeyboard; + graphics::NotificationRenderer::virtualKeyboard = nullptr; + graphics::NotificationRenderer::textInputCallback = nullptr; + graphics::NotificationRenderer::resetBanner(); + } + this->notifyObservers(&e); } else if (this->runState == CANNED_MESSAGE_RUN_STATE_ACTION_SELECT) { - if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { + if (this->payload == 0) { + // [Exit] button pressed - return to inactive state + LOG_INFO("Processing [Exit] action - returning to inactive state"); + this->runState = CANNED_MESSAGE_RUN_STATE_INACTIVE; + } else if (this->payload == CANNED_MESSAGE_RUN_STATE_FREETEXT) { if (this->freetext.length() > 0) { sendText(this->dest, this->channel, this->freetext.c_str(), true); this->runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; From af91b787218bb2c669eb250d5420fc22f50ab42c Mon Sep 17 00:00:00 2001 From: whywilson Date: Fri, 25 Jul 2025 17:00:05 +0800 Subject: [PATCH 2/6] Add long press repeat interval. --- src/input/TrackballInterruptBase.cpp | 9 +++++++-- src/input/TrackballInterruptBase.h | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index e84e32705..c1424550f 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -73,12 +73,17 @@ int32_t TrackballInterruptBase::runOnce() // Reset state pressDetected = false; pressStartTime = 0; + lastLongPressEventTime = 0; this->action = TB_ACTION_NONE; } else if (pressDuration >= LONG_PRESS_DURATION) { // Long press detected - e.inputEvent = this->_eventPressedLong; + uint32_t currentTime = millis(); + // Only trigger long press event if enough time has passed since the last one + if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { + e.inputEvent = this->_eventPressedLong; + lastLongPressEventTime = currentTime; + } this->action = TB_ACTION_PRESSED_LONG; - // Keep pressDetected true to avoid repeated long press events } } diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 30967fe7f..38be22f20 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -50,7 +50,9 @@ class TrackballInterruptBase : public Observable, public con // Long press detection for press button uint32_t pressStartTime = 0; bool pressDetected = false; - static const uint32_t LONG_PRESS_DURATION = 500; // ms + uint32_t lastLongPressEventTime = 0; + static const uint32_t LONG_PRESS_DURATION = 500; // ms + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 500; // ms - interval between repeated long press events private: input_broker_event _eventDown = INPUT_BROKER_NONE; From 949d96a6762fc2db25a4d345520abdc9296af11b Mon Sep 17 00:00:00 2001 From: whywilson Date: Fri, 25 Jul 2025 17:04:21 +0800 Subject: [PATCH 3/6] Add just input area string Y position. --- src/graphics/VirtualKeyboard.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index 521cad360..dedd1595c 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -163,7 +163,7 @@ void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16 // Draw text inside the box - properly centered vertically in the input box int textX = offsetX + 4; - int textY = boxY - 1; // Moved down by 1 pixel + int textY = boxY; // Moved down by 1 pixel if (!scrolledText.empty()) { display->drawString(textX, textY, scrolledText.c_str()); @@ -175,7 +175,7 @@ void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16 // Ensure cursor stays within box bounds if (cursorX < offsetX + boxWidth - 2) { // Align cursor properly with the text baseline and height - moved down by 2 pixels - display->drawVerticalLine(cursorX, textY + 3, 10); + display->drawVerticalLine(cursorX, textY + 2, 10); } } } From 00be80979448af495fbf1c6eb8cd199df996a459 Mon Sep 17 00:00:00 2001 From: whywilson Date: Sat, 26 Jul 2025 07:17:07 +0800 Subject: [PATCH 4/6] Add virtural keyboard for upDownInterrupter and RotaryEncoder. --- src/buzz/BuzzerFeedbackThread.cpp | 2 + src/graphics/VirtualKeyboard.cpp | 25 +++-- src/graphics/draw/NotificationRenderer.cpp | 49 ++++++---- src/input/InputBroker.h | 4 +- src/input/TrackballInterruptBase.cpp | 24 ++--- src/input/TrackballInterruptBase.h | 2 +- src/input/UpDownInterruptBase.cpp | 106 +++++++++++++++++---- src/input/UpDownInterruptBase.h | 30 +++++- src/input/UpDownInterruptImpl1.cpp | 8 +- 9 files changed, 183 insertions(+), 67 deletions(-) diff --git a/src/buzz/BuzzerFeedbackThread.cpp b/src/buzz/BuzzerFeedbackThread.cpp index 838224c69..12b30a705 100644 --- a/src/buzz/BuzzerFeedbackThread.cpp +++ b/src/buzz/BuzzerFeedbackThread.cpp @@ -33,7 +33,9 @@ int BuzzerFeedbackThread::handleInputEvent(const InputEvent *event) break; case INPUT_BROKER_UP: + case INPUT_BROKER_UP_LONG: case INPUT_BROKER_DOWN: + case INPUT_BROKER_DOWN_LONG: case INPUT_BROKER_LEFT: case INPUT_BROKER_RIGHT: playChirp(); // Navigation feedback diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index dedd1595c..8e98145f2 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -64,6 +64,9 @@ void VirtualKeyboard::initializeKeyboard() void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY) { + // Clear the display to avoid overlapping with other UI elements + display->clear(); + // Set initial color and font display->setColor(WHITE); display->setFont(FONT_SMALL); @@ -145,7 +148,7 @@ void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16 } // Handle text overflow with scrolling - int textPadding = 4; + int textPadding = 6; // 2px left padding + 4px right padding for cursor space int maxWidth = boxWidth - textPadding; int textWidth = display->getStringWidth(displayText.c_str()); @@ -158,6 +161,13 @@ void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16 } if (scrolledText != displayText) { scrolledText = "..." + scrolledText; + // Recalculate width with ellipsis and ensure it still fits + textWidth = display->getStringWidth(scrolledText.c_str()); + while (textWidth > maxWidth && scrolledText.length() > 3) { + // Remove one character after "..." and recalculate + scrolledText = "..." + scrolledText.substr(4); + textWidth = display->getStringWidth(scrolledText.c_str()); + } } } @@ -172,8 +182,9 @@ void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16 // Draw cursor at the end of visible text - aligned with text baseline if (!inputText.empty() || true) { // Always show cursor for visibility int cursorX = textX + display->getStringWidth(scrolledText.c_str()); - // Ensure cursor stays within box bounds - if (cursorX < offsetX + boxWidth - 2) { + // Ensure cursor stays within box bounds with proper margin from right edge + int rightBoundary = offsetX + 2 + boxWidth - 3; // 3px margin from right border + if (cursorX < rightBoundary) { // Align cursor properly with the text baseline and height - moved down by 2 pixels display->drawVerticalLine(cursorX, textY + 2, 10); } @@ -277,7 +288,7 @@ char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress) void VirtualKeyboard::moveCursorUp() { - resetTimeout(); // Reset timeout on any input activity + resetTimeout(); // If we're on the close button, move to keyboard if (cursorOnCloseButton) { @@ -322,7 +333,7 @@ void VirtualKeyboard::moveCursorUp() void VirtualKeyboard::moveCursorDown() { - resetTimeout(); // Reset timeout on any input activity + resetTimeout(); uint8_t originalRow = cursorRow; if (cursorRow < KEYBOARD_ROWS - 1) { @@ -354,7 +365,7 @@ void VirtualKeyboard::moveCursorDown() void VirtualKeyboard::moveCursorLeft() { - resetTimeout(); // Reset timeout on any input activity + resetTimeout(); // Find the previous valid key position do { @@ -375,7 +386,7 @@ void VirtualKeyboard::moveCursorLeft() void VirtualKeyboard::moveCursorRight() { - resetTimeout(); // Reset timeout on any input activity + resetTimeout(); // If we're on the close button, go back to keyboard if (cursorOnCloseButton) { diff --git a/src/graphics/draw/NotificationRenderer.cpp b/src/graphics/draw/NotificationRenderer.cpp index 389baa82b..e7334a1e2 100644 --- a/src/graphics/draw/NotificationRenderer.cpp +++ b/src/graphics/draw/NotificationRenderer.cpp @@ -7,10 +7,15 @@ #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/images.h" +#include "input/RotaryEncoderInterruptImpl1.h" +#include "input/UpDownInterruptImpl1.h" #include "main.h" #include #include #include +#if HAS_TRACKBALL +#include "input/TrackballInterruptImpl1.h" +#endif #ifdef ARCH_ESP32 #include "esp_task_wdt.h" @@ -125,6 +130,9 @@ void NotificationRenderer::drawBannercallback(OLEDDisplay *display, OLEDDisplayU case notificationTypeEnum::number_picker: drawNumberPicker(display, state); break; + case notificationTypeEnum::text_input: + // text_input is handled at the top of the function + break; } } @@ -615,59 +623,64 @@ void NotificationRenderer::drawTextInput(OLEDDisplay *display, OLEDDisplayUiStat return; } - // Handle input events for virtual keyboard navigation if (inEvent.inputEvent != INPUT_BROKER_NONE) { if (inEvent.inputEvent == INPUT_BROKER_UP) { - virtualKeyboard->moveCursorUp(); + // 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) { - virtualKeyboard->moveCursorDown(); + 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) { - // Long press UP = move left virtualKeyboard->moveCursorLeft(); } else if (inEvent.inputEvent == INPUT_BROKER_USER_PRESS) { - // Long press DOWN = move right 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) { - // Cancel virtual keyboard - call callback with empty string - auto callback = textInputCallback; // Store callback before clearing - - // Clean up first to prevent re-entry + auto callback = textInputCallback; 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; } - // Reset input event after processing + // Consume the event after processing for virtual keyboard inEvent.inputEvent = INPUT_BROKER_NONE; } - // Clear the display and draw virtual keyboard - display->setColor(BLACK); - display->fillRect(0, 0, display->getWidth(), display->getHeight()); - display->setColor(WHITE); + // Draw the virtual keyboard - clear only when needed virtualKeyboard->draw(display, 0, 0); } else { // If virtualKeyboard is null, reset the banner to avoid getting stuck + LOG_INFO("Virtual keyboard is null - resetting banner"); resetBanner(); } } diff --git a/src/input/InputBroker.h b/src/input/InputBroker.h index 012a403f5..093b82592 100644 --- a/src/input/InputBroker.h +++ b/src/input/InputBroker.h @@ -4,13 +4,15 @@ enum input_broker_event { INPUT_BROKER_NONE = 0, INPUT_BROKER_SELECT = 10, - INPUT_BROKER_SELECT_LONG, INPUT_BROKER_UP = 17, INPUT_BROKER_DOWN = 18, INPUT_BROKER_LEFT = 19, INPUT_BROKER_RIGHT = 20, INPUT_BROKER_CANCEL = 24, INPUT_BROKER_BACK = 27, + INPUT_BROKER_SELECT_LONG = 28, + INPUT_BROKER_UP_LONG = 29, + INPUT_BROKER_DOWN_LONG = 30, INPUT_BROKER_USER_PRESS, INPUT_BROKER_ALT_PRESS, INPUT_BROKER_ALT_LONG, diff --git a/src/input/TrackballInterruptBase.cpp b/src/input/TrackballInterruptBase.cpp index c1424550f..840e73b28 100644 --- a/src/input/TrackballInterruptBase.cpp +++ b/src/input/TrackballInterruptBase.cpp @@ -42,8 +42,8 @@ void TrackballInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinLef attachInterrupt(this->_pinRight, onIntRight, TB_DIRECTION); } - LOG_DEBUG("Trackball GPIO initialized (%d, %d, %d, %d, %d)", this->_pinUp, this->_pinDown, this->_pinLeft, this->_pinRight, - pinPress); + LOG_DEBUG("Trackball GPIO initialized - UP:%d DOWN:%d LEFT:%d RIGHT:%d PRESS:%d", this->_pinUp, this->_pinDown, + this->_pinLeft, this->_pinRight, pinPress); this->setInterval(100); } @@ -65,9 +65,8 @@ int32_t TrackballInterruptBase::runOnce() #endif if (!buttonStillPressed) { - // Button released + // Button released - check if it was a short press if (pressDuration < LONG_PRESS_DURATION) { - // Short press e.inputEvent = this->_eventPressed; } // Reset state @@ -75,15 +74,10 @@ int32_t TrackballInterruptBase::runOnce() pressStartTime = 0; lastLongPressEventTime = 0; this->action = TB_ACTION_NONE; - } else if (pressDuration >= LONG_PRESS_DURATION) { - // Long press detected - uint32_t currentTime = millis(); - // Only trigger long press event if enough time has passed since the last one - if (lastLongPressEventTime == 0 || (currentTime - lastLongPressEventTime) >= LONG_PRESS_REPEAT_INTERVAL) { - e.inputEvent = this->_eventPressedLong; - lastLongPressEventTime = currentTime; - } - this->action = TB_ACTION_PRESSED_LONG; + } else if (pressDuration >= LONG_PRESS_DURATION && lastLongPressEventTime == 0) { + // First long press event only - avoid repeated events that cause lag + e.inputEvent = this->_eventPressedLong; + lastLongPressEventTime = millis(); } } @@ -113,16 +107,12 @@ int32_t TrackballInterruptBase::runOnce() pressStartTime = millis(); // Don't send event yet, wait to see if it's a long press } else if (this->action == TB_ACTION_UP && !digitalRead(_pinUp)) { - // LOG_DEBUG("Trackball event UP"); e.inputEvent = this->_eventUp; } else if (this->action == TB_ACTION_DOWN && !digitalRead(_pinDown)) { - // LOG_DEBUG("Trackball event DOWN"); e.inputEvent = this->_eventDown; } else if (this->action == TB_ACTION_LEFT && !digitalRead(_pinLeft)) { - // LOG_DEBUG("Trackball event LEFT"); e.inputEvent = this->_eventLeft; } else if (this->action == TB_ACTION_RIGHT && !digitalRead(_pinRight)) { - // LOG_DEBUG("Trackball event RIGHT"); e.inputEvent = this->_eventRight; } #endif diff --git a/src/input/TrackballInterruptBase.h b/src/input/TrackballInterruptBase.h index 38be22f20..c2bae506f 100644 --- a/src/input/TrackballInterruptBase.h +++ b/src/input/TrackballInterruptBase.h @@ -52,7 +52,7 @@ class TrackballInterruptBase : public Observable, public con bool pressDetected = false; uint32_t lastLongPressEventTime = 0; static const uint32_t LONG_PRESS_DURATION = 500; // ms - static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 500; // ms - interval between repeated long press events + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 300; // ms - interval between repeated long press events private: input_broker_event _eventDown = INPUT_BROKER_NONE; diff --git a/src/input/UpDownInterruptBase.cpp b/src/input/UpDownInterruptBase.cpp index c66eb13d0..5417483f4 100644 --- a/src/input/UpDownInterruptBase.cpp +++ b/src/input/UpDownInterruptBase.cpp @@ -7,14 +7,19 @@ UpDownInterruptBase::UpDownInterruptBase(const char *name) : concurrency::OSThre } void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, - input_broker_event eventUp, input_broker_event eventPressed, void (*onIntDown)(), + input_broker_event eventUp, input_broker_event eventPressed, input_broker_event eventPressedLong, + input_broker_event eventUpLong, input_broker_event eventDownLong, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), unsigned long updownDebounceMs) { this->_pinDown = pinDown; this->_pinUp = pinUp; + this->_pinPress = pinPress; this->_eventDown = eventDown; this->_eventUp = eventUp; this->_eventPressed = eventPressed; + this->_eventPressedLong = eventPressedLong; + this->_eventUpLong = eventUpLong; + this->_eventDownLong = eventDownLong; pinMode(pinPress, INPUT_PULLUP); pinMode(this->_pinDown, INPUT_PULLUP); @@ -26,7 +31,7 @@ void UpDownInterruptBase::init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, LOG_DEBUG("Up/down/press GPIO initialized (%d, %d, %d)", this->_pinUp, this->_pinDown, pinPress); - this->setInterval(100); + this->setInterval(20); } int32_t UpDownInterruptBase::runOnce() @@ -34,23 +39,83 @@ int32_t UpDownInterruptBase::runOnce() InputEvent e; e.inputEvent = INPUT_BROKER_NONE; unsigned long now = millis(); - if (this->action == UPDOWN_ACTION_PRESSED) { - if (now - lastPressKeyTime >= pressDebounceMs) { - lastPressKeyTime = now; - LOG_DEBUG("GPIO event Press"); - e.inputEvent = this->_eventPressed; + + // Read all button states once at the beginning + bool pressButtonPressed = !digitalRead(_pinPress); + bool upButtonPressed = !digitalRead(_pinUp); + bool downButtonPressed = !digitalRead(_pinDown); + + // Handle initial button press detection - only if not already detected + if (this->action == UPDOWN_ACTION_PRESSED && pressButtonPressed && !pressDetected) { + pressDetected = true; + pressStartTime = now; + } else if (this->action == UPDOWN_ACTION_UP && upButtonPressed && !upDetected) { + upDetected = true; + upStartTime = now; + } else if (this->action == UPDOWN_ACTION_DOWN && downButtonPressed && !downDetected) { + downDetected = true; + downStartTime = now; + } + + // Handle long press detection for press button + if (pressDetected && pressStartTime > 0) { + uint32_t pressDuration = now - pressStartTime; + + if (!pressButtonPressed) { + // Button released + if (pressDuration < LONG_PRESS_DURATION && now - lastPressKeyTime >= pressDebounceMs) { + lastPressKeyTime = now; + e.inputEvent = this->_eventPressed; + } + // Reset state + pressDetected = false; + pressStartTime = 0; + lastPressLongEventTime = 0; + } else if (pressDuration >= LONG_PRESS_DURATION && lastPressLongEventTime == 0) { + // First long press event only - avoid repeated events causing lag + e.inputEvent = this->_eventPressedLong; + lastPressLongEventTime = now; } - } else if (this->action == UPDOWN_ACTION_UP) { - if (now - lastUpKeyTime >= updownDebounceMs) { - lastUpKeyTime = now; - LOG_DEBUG("GPIO event Up"); - e.inputEvent = this->_eventUp; + } + + // Handle long press detection for up button + if (upDetected && upStartTime > 0) { + uint32_t upDuration = now - upStartTime; + + if (!upButtonPressed) { + // Button released + if (upDuration < LONG_PRESS_DURATION && now - lastUpKeyTime >= updownDebounceMs) { + lastUpKeyTime = now; + e.inputEvent = this->_eventUp; + } + // Reset state + upDetected = false; + upStartTime = 0; + lastUpLongEventTime = 0; + } else if (upDuration >= LONG_PRESS_DURATION && lastUpLongEventTime == 0) { + // First long press event only - avoid repeated events causing lag + e.inputEvent = this->_eventUpLong; + lastUpLongEventTime = now; } - } else if (this->action == UPDOWN_ACTION_DOWN) { - if (now - lastDownKeyTime >= updownDebounceMs) { - lastDownKeyTime = now; - LOG_DEBUG("GPIO event Down"); - e.inputEvent = this->_eventDown; + } + + // Handle long press detection for down button + if (downDetected && downStartTime > 0) { + uint32_t downDuration = now - downStartTime; + + if (!downButtonPressed) { + // Button released + if (downDuration < LONG_PRESS_DURATION && now - lastDownKeyTime >= updownDebounceMs) { + lastDownKeyTime = now; + e.inputEvent = this->_eventDown; + } + // Reset state + downDetected = false; + downStartTime = 0; + lastDownLongEventTime = 0; + } else if (downDuration >= LONG_PRESS_DURATION && lastDownLongEventTime == 0) { + e.inputEvent = this->_eventDownLong; + lastDownLongEventTime = now; } } @@ -60,8 +125,11 @@ int32_t UpDownInterruptBase::runOnce() this->notifyObservers(&e); } - this->action = UPDOWN_ACTION_NONE; - return 100; + if (!pressDetected && !upDetected && !downDetected) { + this->action = UPDOWN_ACTION_NONE; + } + + return 20; // This will control how the input frequency } void UpDownInterruptBase::intPressHandler() diff --git a/src/input/UpDownInterruptBase.h b/src/input/UpDownInterruptBase.h index a83a298f2..ae84efdaf 100644 --- a/src/input/UpDownInterruptBase.h +++ b/src/input/UpDownInterruptBase.h @@ -8,7 +8,8 @@ class UpDownInterruptBase : public Observable, public concur public: explicit UpDownInterruptBase(const char *name); void init(uint8_t pinDown, uint8_t pinUp, uint8_t pinPress, input_broker_event eventDown, input_broker_event eventUp, - input_broker_event eventPressed, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), + input_broker_event eventPressed, input_broker_event eventPressedLong, input_broker_event eventUpLong, + input_broker_event eventDownLong, void (*onIntDown)(), void (*onIntUp)(), void (*onIntPress)(), unsigned long updownDebounceMs = 50); void intPressHandler(); void intDownHandler(); @@ -17,16 +18,41 @@ class UpDownInterruptBase : public Observable, public concur int32_t runOnce() override; protected: - enum UpDownInterruptBaseActionType { UPDOWN_ACTION_NONE, UPDOWN_ACTION_PRESSED, UPDOWN_ACTION_UP, UPDOWN_ACTION_DOWN }; + enum UpDownInterruptBaseActionType { + UPDOWN_ACTION_NONE, + UPDOWN_ACTION_PRESSED, + UPDOWN_ACTION_PRESSED_LONG, + UPDOWN_ACTION_UP, + UPDOWN_ACTION_UP_LONG, + UPDOWN_ACTION_DOWN, + UPDOWN_ACTION_DOWN_LONG + }; volatile UpDownInterruptBaseActionType action = UPDOWN_ACTION_NONE; + // Long press detection variables + uint32_t pressStartTime = 0; + uint32_t upStartTime = 0; + uint32_t downStartTime = 0; + bool pressDetected = false; + bool upDetected = false; + bool downDetected = false; + uint32_t lastPressLongEventTime = 0; + uint32_t lastUpLongEventTime = 0; + uint32_t lastDownLongEventTime = 0; + static const uint32_t LONG_PRESS_DURATION = 300; + static const uint32_t LONG_PRESS_REPEAT_INTERVAL = 300; + private: uint8_t _pinDown = 0; uint8_t _pinUp = 0; + uint8_t _pinPress = 0; input_broker_event _eventDown = INPUT_BROKER_NONE; input_broker_event _eventUp = INPUT_BROKER_NONE; input_broker_event _eventPressed = INPUT_BROKER_NONE; + input_broker_event _eventPressedLong = INPUT_BROKER_NONE; + input_broker_event _eventUpLong = INPUT_BROKER_NONE; + input_broker_event _eventDownLong = INPUT_BROKER_NONE; const char *_originName; unsigned long lastUpKeyTime = 0; diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 761b92348..66623e607 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -20,9 +20,13 @@ bool UpDownInterruptImpl1::init() input_broker_event eventDown = INPUT_BROKER_DOWN; input_broker_event eventUp = INPUT_BROKER_UP; input_broker_event eventPressed = INPUT_BROKER_SELECT; + input_broker_event eventPressedLong = INPUT_BROKER_SELECT_LONG; + input_broker_event eventUpLong = INPUT_BROKER_UP_LONG; + input_broker_event eventDownLong = INPUT_BROKER_DOWN_LONG; - UpDownInterruptBase::init(pinDown, pinUp, pinPress, eventDown, eventUp, eventPressed, UpDownInterruptImpl1::handleIntDown, - UpDownInterruptImpl1::handleIntUp, UpDownInterruptImpl1::handleIntPressed); + UpDownInterruptBase::init(pinDown, pinUp, pinPress, eventDown, eventUp, eventPressed, eventPressedLong, eventUpLong, + eventDownLong, UpDownInterruptImpl1::handleIntDown, UpDownInterruptImpl1::handleIntUp, + UpDownInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); return true; } From da8963355edf70fea608d3dbf6b1cfab7e0a5d91 Mon Sep 17 00:00:00 2001 From: whywilson Date: Sat, 26 Jul 2025 21:39:45 +0800 Subject: [PATCH 5/6] Add ScreenFonts.h for font height. --- src/graphics/VirtualKeyboard.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/graphics/VirtualKeyboard.cpp b/src/graphics/VirtualKeyboard.cpp index 8e98145f2..aca229902 100644 --- a/src/graphics/VirtualKeyboard.cpp +++ b/src/graphics/VirtualKeyboard.cpp @@ -1,6 +1,7 @@ #include "VirtualKeyboard.h" #include "configuration.h" #include "graphics/Screen.h" +#include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "main.h" #include From f4af02544ec1f220dd89541e5cb0985a5d147b6d Mon Sep 17 00:00:00 2001 From: whywilson Date: Mon, 28 Jul 2025 08:27:29 +0800 Subject: [PATCH 6/6] Add osk_found On-Screen Keyboard found as the global variable. --- src/input/RotaryEncoderInterruptImpl1.cpp | 2 ++ src/input/UpDownInterruptImpl1.cpp | 2 ++ src/main.cpp | 6 ++++++ src/main.h | 1 + src/modules/CannedMessageModule.cpp | 24 +++-------------------- 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/input/RotaryEncoderInterruptImpl1.cpp b/src/input/RotaryEncoderInterruptImpl1.cpp index 4f19c8b0b..f17b02483 100644 --- a/src/input/RotaryEncoderInterruptImpl1.cpp +++ b/src/input/RotaryEncoderInterruptImpl1.cpp @@ -1,5 +1,6 @@ #include "RotaryEncoderInterruptImpl1.h" #include "InputBroker.h" +extern bool osk_found; RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; @@ -25,6 +26,7 @@ bool RotaryEncoderInterruptImpl1::init() RotaryEncoderInterruptImpl1::handleIntA, RotaryEncoderInterruptImpl1::handleIntB, RotaryEncoderInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); + osk_found = true; return true; } diff --git a/src/input/UpDownInterruptImpl1.cpp b/src/input/UpDownInterruptImpl1.cpp index 66623e607..7e71e6428 100644 --- a/src/input/UpDownInterruptImpl1.cpp +++ b/src/input/UpDownInterruptImpl1.cpp @@ -1,5 +1,6 @@ #include "UpDownInterruptImpl1.h" #include "InputBroker.h" +extern bool osk_found; UpDownInterruptImpl1 *upDownInterruptImpl1; @@ -28,6 +29,7 @@ bool UpDownInterruptImpl1::init() eventDownLong, UpDownInterruptImpl1::handleIntDown, UpDownInterruptImpl1::handleIntUp, UpDownInterruptImpl1::handleIntPressed); inputBroker->registerSource(this); + osk_found = true; return true; } diff --git a/src/main.cpp b/src/main.cpp index 1868d98c7..72ce52fcd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -187,6 +187,8 @@ ScanI2C::DeviceAddress cardkb_found = ScanI2C::ADDRESS_NONE; uint8_t kb_model; // global bool to record that a kb is present bool kb_found = false; +// global bool to record that on-screen keyboard (OSK) is present +bool osk_found = false; // The I2C address of the RTC Module (if found) ScanI2C::DeviceAddress rtc_found = ScanI2C::ADDRESS_NONE; @@ -1390,6 +1392,10 @@ void setup() #endif #endif +#if defined(HAS_TRACKBALL) || (defined(INPUTDRIVER_ENCODER_TYPE) && INPUTDRIVER_ENCODER_TYPE == 2) + osk_found = true; +#endif + #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WEBSERVER // Start web server thread. webServerThread = new WebServerThread(); diff --git a/src/main.h b/src/main.h index 7105bd62b..15ac16533 100644 --- a/src/main.h +++ b/src/main.h @@ -32,6 +32,7 @@ extern ScanI2C::DeviceAddress screen_found; extern ScanI2C::DeviceAddress cardkb_found; extern uint8_t kb_model; extern bool kb_found; +extern bool osk_found; extern ScanI2C::DeviceAddress rtc_found; extern ScanI2C::DeviceAddress accelerometer_found; extern ScanI2C::FoundDevice rgb_found; diff --git a/src/modules/CannedMessageModule.cpp b/src/modules/CannedMessageModule.cpp index 7cafbc8a7..7ab52d7c5 100644 --- a/src/modules/CannedMessageModule.cpp +++ b/src/modules/CannedMessageModule.cpp @@ -42,6 +42,7 @@ extern ScanI2C::DeviceAddress cardkb_found; extern bool graphics::isMuted; +extern bool osk_found; static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto"; @@ -125,20 +126,11 @@ int CannedMessageModule::splitConfiguredMessages() int tempCount = 0; // Insert at position 0 (top) tempMessages[tempCount++] = "[Select Destination]"; - #if defined(USE_VIRTUAL_KEYBOARD) // Add a "Free Text" entry at the top if using a touch screen virtual keyboard tempMessages[tempCount++] = "[-- Free Text --]"; #else - // For devices with encoder input or trackball, also add Free Text option -#if HAS_TRACKBALL - extern TrackballInterruptImpl1 *trackballInterruptImpl1; - if (trackballInterruptImpl1) { -#else - extern RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; - extern UpDownInterruptImpl1 *upDownInterruptImpl1; - if (rotaryEncoderInterruptImpl1 || upDownInterruptImpl1) { -#endif + if (osk_found && screen) { tempMessages[tempCount++] = "[-- Free Text --]"; } #endif @@ -610,18 +602,9 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo } #else if (strcmp(current, "[-- Free Text --]") == 0) { -#if HAS_TRACKBALL - extern TrackballInterruptImpl1 *trackballInterruptImpl1; - if (trackballInterruptImpl1 && screen) { -#else - extern RotaryEncoderInterruptImpl1 *rotaryEncoderInterruptImpl1; - extern UpDownInterruptImpl1 *upDownInterruptImpl1; - if ((rotaryEncoderInterruptImpl1 || upDownInterruptImpl1) && screen) { -#endif + if (osk_found && screen) { screen->showTextInput("Free Text", "", 300000, [this](const std::string &text) { - LOG_INFO("Free text submitted: '%s'", text.c_str()); if (!text.empty()) { - LOG_INFO("Storing message for delayed sending: '%s'", text.c_str()); this->freetext = text.c_str(); this->payload = CANNED_MESSAGE_RUN_STATE_FREETEXT; runState = CANNED_MESSAGE_RUN_STATE_SENDING_ACTIVE; @@ -633,7 +616,6 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo screen->forceDisplay(); setIntervalFromNow(500); - LOG_INFO("Free text callback completed safely"); return; } else { // Don't delete virtual keyboard immediately - it might still be executing