On screen keyboard (#7705)

* Add on-screen keyboard implementation on Trackball device.

* Update On-Screen Keyboard to new layout.

* The on-screen keyboard dynamically adjusts the key size based on the screen.

* Improve input box display on small screens.

* Optimize the virtual keyboard layout and cursor movement logic, and adjust the keyboard starting position for small and wide screens.

* Optimize the text alignment of numeric keys on ssd1306.

---------

Co-authored-by: whywilson <m.tools@qq.com>
This commit is contained in:
Ben Meadors 2025-08-21 06:31:27 -05:00 committed by GitHub
parent 1daf5aad1f
commit 093a37a2b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1227 additions and 31 deletions

View File

@ -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;

View File

@ -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<void(const std::string &)> 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;
@ -713,13 +751,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
@ -741,7 +785,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;
@ -777,6 +823,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
@ -867,6 +914,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
@ -1313,6 +1365,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)
@ -1335,6 +1392,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

View File

@ -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<void(uint32_t)> bannerCallback);
void showNumberPicker(const char *message, uint32_t durationMs, uint8_t digits, std::function<void(uint32_t)> bannerCallback);
void showTextInput(const char *header, const char *initialText, uint32_t durationMs,
std::function<void(const std::string &)> textCallback);
void requestMenu(graphics::menuHandler::screenMenus menuToShow)
{

View File

@ -0,0 +1,738 @@
#include "VirtualKeyboard.h"
#include "configuration.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "main.h"
#include <Arduino.h>
#include <vector>
namespace graphics
{
VirtualKeyboard::VirtualKeyboard() : cursorRow(0), cursorCol(0), lastActivityTime(millis())
{
initializeKeyboard();
// Set cursor to H(2, 5)
cursorRow = 2;
cursorCol = 5;
}
VirtualKeyboard::~VirtualKeyboard() {}
void VirtualKeyboard::initializeKeyboard()
{
// New 4 row, 11 column keyboard layout:
static const char LAYOUT[KEYBOARD_ROWS][KEYBOARD_COLS] = {{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '\b'},
{'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '\n'},
{'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', ' '},
{'z', 'x', 'c', 'v', 'b', 'n', 'm', '.', ',', '?', '\x1b'}};
// Derive layout dimensions and assert they match the configured keyboard grid
constexpr int LAYOUT_ROWS = (int)(sizeof(LAYOUT) / sizeof(LAYOUT[0]));
constexpr int LAYOUT_COLS = (int)(sizeof(LAYOUT[0]) / sizeof(LAYOUT[0][0]));
static_assert(LAYOUT_ROWS == KEYBOARD_ROWS, "LAYOUT rows must equal KEYBOARD_ROWS");
static_assert(LAYOUT_COLS == KEYBOARD_COLS, "LAYOUT cols must equal KEYBOARD_COLS");
// Initialize all keys to empty first
for (int row = 0; row < LAYOUT_ROWS; row++) {
for (int col = 0; col < LAYOUT_COLS; col++) {
keyboard[row][col] = {0, VK_CHAR, 0, 0, 0, 0};
}
}
// Fill keyboard from the 2D layout
for (int row = 0; row < LAYOUT_ROWS; row++) {
for (int col = 0; col < LAYOUT_COLS; col++) {
char ch = LAYOUT[row][col];
// No empty slots in the simplified layout
VirtualKeyType type = VK_CHAR;
if (ch == '\b') {
type = VK_BACKSPACE;
} else if (ch == '\n') {
type = VK_ENTER;
} else if (ch == '\x1b') { // ESC
type = VK_ESC;
} else if (ch == ' ') {
type = VK_SPACE;
}
// Make action keys wider to fit text while keeping the last column aligned
uint8_t width = (type == VK_BACKSPACE || type == VK_ENTER || type == VK_SPACE) ? (KEY_WIDTH * 3) : KEY_WIDTH;
keyboard[row][col] = {ch, type, (uint8_t)(col * KEY_WIDTH), (uint8_t)(row * KEY_HEIGHT), width, KEY_HEIGHT};
}
}
}
void VirtualKeyboard::draw(OLEDDisplay *display, int16_t offsetX, int16_t offsetY)
{
// Repeat ticking is driven by NotificationRenderer once per frame
// Base styles
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Screen geometry
const int screenW = display->getWidth();
const int screenH = display->getHeight();
// Decide wide-screen mode: if there is comfortable width, allow taller keys and reserve fixed width for last column labels
// Heuristic: if screen width >= 200px (e.g., 240x135), treat as wide
const bool isWide = screenW >= 200;
// Determine last-column label max width
display->setFont(FONT_SMALL);
const int wENTER = display->getStringWidth("ENTER");
int lastColLabelW = wENTER; // ENTER is usually the widest
// Smaller padding on very small screens to avoid excessive whitespace
const int lastColPad = (screenW <= 128 ? 2 : 6);
const int reservedLastColW = lastColLabelW + lastColPad; // reserved width for last column keys
// Always reserve width for the rightmost text column to avoid overlap on small screens
int cellW = 0;
int leftoverW = 0;
{
const int leftCols = KEYBOARD_COLS - 1; // 10 input characters
int usableW = screenW - reservedLastColW;
if (usableW < leftCols) {
// Guard: ensure at least 1px per left cell if labels are extremely wide (unlikely)
usableW = leftCols;
}
cellW = usableW / leftCols;
leftoverW = usableW - cellW * leftCols; // distribute extra pixels over left columns (left to right)
}
// Dynamic key geometry
int cellH = KEY_HEIGHT;
int keyboardStartY = 0;
if (screenH <= 64) {
const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL - 2);
const int gapBelowHeader = 0;
const int singleLineBoxHeight = FONT_HEIGHT_SMALL;
const int gapAboveKeyboard = 0;
keyboardStartY = offsetY + headerHeight + gapBelowHeader + singleLineBoxHeight + gapAboveKeyboard;
if (keyboardStartY < 0)
keyboardStartY = 0;
if (keyboardStartY > screenH)
keyboardStartY = screenH;
int keyboardHeight = screenH - keyboardStartY;
cellH = std::max(1, keyboardHeight / KEYBOARD_ROWS);
} else if (isWide) {
// For wide screens (e.g., T114 240x135), prefer square keys: height equals left-column key width.
cellH = std::max((int)KEY_HEIGHT, cellW);
// Guarantee at least 2 lines of input are visible by reducing cell height minimally if needed.
// Replicate the spacing used in drawInputArea(): headerGap=1, box-to-header gap=1, gap above keyboard=1
display->setFont(FONT_SMALL);
const int headerHeight = headerText.empty() ? 0 : (FONT_HEIGHT_SMALL + 1);
const int headerToBoxGap = 1;
const int gapAboveKb = 1;
const int minBoxHeightForTwoLines = 2 * FONT_HEIGHT_SMALL + 2; // inner 1px top/bottom
int maxKeyboardHeight = screenH - (offsetY + headerHeight + headerToBoxGap + minBoxHeightForTwoLines + gapAboveKb);
int maxCellHAllowed = maxKeyboardHeight / KEYBOARD_ROWS;
if (maxCellHAllowed < (int)KEY_HEIGHT)
maxCellHAllowed = KEY_HEIGHT;
if (maxCellHAllowed > 0 && cellH > maxCellHAllowed) {
cellH = maxCellHAllowed;
}
// Keyboard placement from bottom for wide screens
int keyboardHeight = KEYBOARD_ROWS * cellH;
keyboardStartY = screenH - keyboardHeight;
if (keyboardStartY < 0)
keyboardStartY = 0;
} else {
// Default (non-wide, non-64px) behavior: use key height heuristic and place at bottom
cellH = KEY_HEIGHT;
int keyboardHeight = KEYBOARD_ROWS * cellH;
keyboardStartY = screenH - keyboardHeight;
if (keyboardStartY < 0)
keyboardStartY = 0;
}
// Draw input area above keyboard
drawInputArea(display, offsetX, offsetY, keyboardStartY);
// Precompute per-column x and width with leftover distributed over left columns for even spacing
int colX[KEYBOARD_COLS];
int colW[KEYBOARD_COLS];
int runningX = offsetX;
for (int col = 0; col < KEYBOARD_COLS - 1; ++col) {
int wcol = cellW + (col < leftoverW ? 1 : 0);
colX[col] = runningX;
colW[col] = wcol;
runningX += wcol;
}
// Last column
colX[KEYBOARD_COLS - 1] = runningX;
colW[KEYBOARD_COLS - 1] = reservedLastColW;
// Draw keyboard grid
for (int row = 0; row < KEYBOARD_ROWS; row++) {
for (int col = 0; col < KEYBOARD_COLS; col++) {
const VirtualKey &k = keyboard[row][col];
if (k.character != 0 || k.type != VK_CHAR) {
const bool isLastCol = (col == KEYBOARD_COLS - 1);
int x = colX[col];
int w = colW[col];
int y = offsetY + keyboardStartY + row * cellH;
int h = cellH;
bool selected = (row == cursorRow && col == cursorCol);
drawKey(display, k, selected, x, y, (uint8_t)w, (uint8_t)h, isLastCol);
}
}
}
}
void VirtualKeyboard::drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY)
{
display->setColor(WHITE);
const int screenWidth = display->getWidth();
const int screenHeight = display->getHeight();
// Use the standard small font metrics for input box sizing (restore original size)
const int inputLineH = FONT_HEIGHT_SMALL;
// Header uses the standard small (which may be larger on big screens)
display->setFont(FONT_SMALL);
int headerHeight = 0;
if (!headerText.empty()) {
// Draw header and reserve exact font height (plus a tighter gap) to maximize input area
display->drawString(offsetX + 2, offsetY, headerText.c_str());
if (screenHeight <= 64) {
headerHeight = FONT_HEIGHT_SMALL - 2; // 11px
} else {
headerHeight = FONT_HEIGHT_SMALL; // no extra padding baked in
}
}
const int boxX = offsetX;
const int boxWidth = screenWidth;
int boxY;
int boxHeight;
if (screenHeight <= 64) {
const int gapBelowHeader = 0;
const int fixedBoxHeight = inputLineH;
const int gapAboveKeyboard = 0;
boxY = offsetY + headerHeight + gapBelowHeader;
boxHeight = fixedBoxHeight;
if (boxY + boxHeight + gapAboveKeyboard > keyboardStartY) {
int over = boxY + boxHeight + gapAboveKeyboard - keyboardStartY;
boxHeight = std::max(1, fixedBoxHeight - over);
}
} else {
const int gapBelowHeader = 1;
int gapAboveKeyboard = 1;
int tmpBoxY = offsetY + headerHeight + gapBelowHeader;
const int minBoxHeight = inputLineH + 2;
int availableH = keyboardStartY - tmpBoxY - gapAboveKeyboard;
if (availableH < minBoxHeight)
availableH = minBoxHeight;
boxY = tmpBoxY;
boxHeight = availableH;
}
// Draw box border
display->drawRect(boxX, boxY, boxWidth, boxHeight);
display->setFont(FONT_SMALL);
// Text rendering: multi-line if space allows (>= 2 lines), else single-line with leading ellipsis
const int textX = boxX + 2;
const int maxTextWidth = boxWidth - 4;
const int maxLines = (boxHeight - 2) / inputLineH;
if (maxLines >= 2) {
// Inner bounds for caret clamping
const int innerLeft = boxX + 1;
const int innerRight = boxX + boxWidth - 2;
const int innerTop = boxY + 1;
const int innerBottom = boxY + boxHeight - 2;
// Wrap text greedily into lines that fit maxTextWidth
std::vector<std::string> lines;
{
std::string remaining = inputText;
while (!remaining.empty()) {
int bestLen = 0;
for (int len = 1; len <= (int)remaining.size(); ++len) {
int w = display->getStringWidth(remaining.substr(0, len).c_str());
if (w <= maxTextWidth)
bestLen = len;
else
break;
}
if (bestLen == 0) {
// At least show one character to make progress
bestLen = 1;
}
lines.emplace_back(remaining.substr(0, bestLen));
remaining.erase(0, bestLen);
}
}
const bool scrolledUp = ((int)lines.size() > maxLines);
int caretX = textX;
int caretY = innerTop;
// Leave a small top gap to render '...' without replacing the first line
const int topInset = 2;
const int lineStep = std::max(1, inputLineH - 1); // slightly tighter than font height
int lineY = innerTop + topInset;
if (scrolledUp) {
// Draw three small dots centered horizontally, vertically at the midpoint of the gap
// between the inner top and the first line's top baseline. This avoids using a tall glyph.
const int firstLineTop = lineY; // baseline top for the first visible line
const int gapMidY = innerTop + (firstLineTop - innerTop) / 2 + 1; // shift down 1px as requested
const int centerX = boxX + boxWidth / 2;
const int dotSpacing = 3; // px between dots
const int dotSize = 1; // small square dot
display->fillRect(centerX - dotSpacing, gapMidY, dotSize, dotSize);
display->fillRect(centerX, gapMidY, dotSize, dotSize);
display->fillRect(centerX + dotSpacing, gapMidY, dotSize, dotSize);
}
// How many lines fit with our top inset and tighter step
const int linesCapacity = std::max(1, (innerBottom - lineY + 1) / lineStep);
const int linesToShow = std::min((int)lines.size(), linesCapacity);
const int startIndex = scrolledUp ? ((int)lines.size() - linesToShow) : 0;
for (int i = 0; i < linesToShow; ++i) {
const std::string &chunk = lines[startIndex + i];
display->drawString(textX, lineY, chunk.c_str());
caretX = textX + display->getStringWidth(chunk.c_str());
caretY = lineY;
lineY += lineStep;
}
// Draw caret at end of the last visible line
int caretPadY = 2;
if (boxHeight >= inputLineH + 4)
caretPadY = 3;
int cursorTop = caretY + caretPadY;
// Use lineStep so caret height matches the row spacing
int cursorH = lineStep - caretPadY * 2;
if (cursorH < 1)
cursorH = 1;
// Clamp vertical bounds to stay inside the inner rect
if (cursorTop < innerTop)
cursorTop = innerTop;
if (cursorTop + cursorH - 1 > innerBottom)
cursorH = innerBottom - cursorTop + 1;
if (cursorH < 1)
cursorH = 1;
// Only draw if cursor is inside inner bounds
if (caretX >= innerLeft && caretX <= innerRight) {
display->drawVerticalLine(caretX, cursorTop, cursorH);
}
} else {
std::string displayText = inputText;
int textW = display->getStringWidth(displayText.c_str());
std::string scrolled = displayText;
if (textW > maxTextWidth) {
// Trim from the left until it fits
while (textW > maxTextWidth && !scrolled.empty()) {
scrolled.erase(0, 1);
textW = display->getStringWidth(scrolled.c_str());
}
// Add leading ellipsis and ensure it still fits
if (scrolled != displayText) {
scrolled = "..." + scrolled;
textW = display->getStringWidth(scrolled.c_str());
// If adding ellipsis causes overflow, trim more after the ellipsis
while (textW > maxTextWidth && scrolled.size() > 3) {
scrolled.erase(3, 1); // remove chars after the ellipsis
textW = display->getStringWidth(scrolled.c_str());
}
}
} else {
// Keep textW in sync with what we draw
textW = display->getStringWidth(scrolled.c_str());
}
int textY;
if (screenHeight <= 64) {
textY = boxY + (boxHeight - inputLineH) / 2;
} else {
const int innerLeft = boxX + 1;
const int innerRight = boxX + boxWidth - 2;
const int innerTop = boxY + 1;
const int innerBottom = boxY + boxHeight - 2;
// Center text vertically within inner box for single-line, then clamp so it never overlaps borders
int innerH = innerBottom - innerTop + 1;
textY = innerTop + std::max(0, (innerH - inputLineH) / 2);
// Clamp fully inside the inner rect
if (textY < innerTop)
textY = innerTop;
int maxTop = innerBottom - inputLineH + 1;
if (textY > maxTop)
textY = maxTop;
}
if (!scrolled.empty()) {
display->drawString(textX, textY, scrolled.c_str());
}
int cursorX = textX + textW;
if (screenHeight > 64) {
const int innerRight = boxX + boxWidth - 2;
if (cursorX > innerRight)
cursorX = innerRight;
}
int cursorTop, cursorH;
if (screenHeight <= 64) {
cursorH = 10;
cursorTop = boxY + (boxHeight - cursorH) / 2;
} else {
const int innerLeft = boxX + 1;
const int innerRight = boxX + boxWidth - 2;
const int innerTop = boxY + 1;
const int innerBottom = boxY + boxHeight - 2;
cursorTop = boxY + 2;
cursorH = boxHeight - 4;
if (cursorH < 1)
cursorH = 1;
if (cursorTop < innerTop)
cursorTop = innerTop;
if (cursorTop + cursorH - 1 > innerBottom)
cursorH = innerBottom - cursorTop + 1;
if (cursorH < 1)
cursorH = 1;
if (cursorX < innerLeft || cursorX > innerRight)
return;
}
display->drawVerticalLine(cursorX, cursorTop, cursorH);
}
}
void VirtualKeyboard::drawKey(OLEDDisplay *display, const VirtualKey &key, bool selected, int16_t x, int16_t y, uint8_t width,
uint8_t height, bool isLastCol)
{
// Draw key content
display->setFont(FONT_SMALL);
const int fontH = FONT_HEIGHT_SMALL;
// Build label and metrics first
std::string keyText;
if (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC) {
// Keep literal text labels for the action keys on the rightmost column
keyText = (key.type == VK_BACKSPACE) ? "BACK"
: (key.type == VK_ENTER) ? "ENTER"
: (key.type == VK_SPACE) ? "SPACE"
: (key.type == VK_ESC) ? "ESC"
: "";
} else {
char c = getCharForKey(key, false);
if (c >= 'a' && c <= 'z') {
c = c - 'a' + 'A';
}
keyText = (key.character == ' ' || key.character == '_') ? "_" : std::string(1, c);
}
int textWidth = display->getStringWidth(keyText.c_str());
// Label alignment
// - Rightmost action column: right-align text with a small right padding (~2px) so it hugs screen edge neatly.
// - Other keys: center horizontally; use ceil-style rounding to avoid appearing left-biased on odd widths.
int textX;
if (isLastCol) {
const int rightPad = 1;
textX = x + width - textWidth - rightPad;
if (textX < x)
textX = x; // guard
} else {
if (display->getHeight() <= 64 && (key.character >= '0' && key.character <= '9')) {
textX = x + (width - textWidth + 1) / 2;
} else {
textX = x + (width - textWidth) / 2;
}
}
int contentTop = y;
int contentH = height;
if (selected) {
display->setColor(WHITE);
bool isAction = (key.type == VK_BACKSPACE || key.type == VK_ENTER || key.type == VK_SPACE || key.type == VK_ESC);
if (display->getHeight() <= 64 && !isAction) {
display->fillRect(x, y, width, height);
} else if (isAction) {
const int padX = 1;
const int padY = 2;
int hlW = textWidth + padX * 2;
int hlX = textX - padX;
if (hlX < x) {
hlW -= (x - hlX);
hlX = x;
}
int maxW = (x + width) - hlX;
if (hlW > maxW)
hlW = maxW;
if (hlW < 1)
hlW = 1;
int hlH = std::min(fontH + padY * 2, (int)height);
int hlY = y + (height - hlH) / 2;
display->fillRect(hlX, hlY, hlW, hlH);
contentTop = hlY;
contentH = hlH;
} else {
display->fillRect(x, y, width, height);
}
display->setColor(BLACK);
} else {
display->setColor(WHITE);
}
int centeredTextY;
if (display->getHeight() <= 64) {
centeredTextY = y + (height - fontH) / 2;
} else {
centeredTextY = contentTop + (contentH - fontH) / 2;
}
if (display->getHeight() > 64) {
if (centeredTextY < contentTop)
centeredTextY = contentTop;
if (centeredTextY + fontH > contentTop + contentH)
centeredTextY = std::max(contentTop, contentTop + contentH - fontH);
}
if (display->getHeight() <= 64 && keyText.size() == 1) {
char ch = keyText[0];
if (ch == '.' || ch == ',' || ch == ';') {
centeredTextY -= 1;
}
}
display->drawString(textX, centeredTextY, keyText.c_str());
}
char VirtualKeyboard::getCharForKey(const VirtualKey &key, bool isLongPress)
{
if (key.type != VK_CHAR) {
return key.character;
}
char c = key.character;
// Long-press: only keep letter lowercase->uppercase conversion; remove other symbol mappings
if (isLongPress && c >= 'a' && c <= 'z') {
c = (char)(c - 'a' + 'A');
}
return c;
}
void VirtualKeyboard::moveCursorDelta(int dRow, int dCol)
{
resetTimeout();
// wrap around rows and cols in the 4x11 grid
int r = (int)cursorRow + dRow;
int c = (int)cursorCol + dCol;
if (r < 0)
r = KEYBOARD_ROWS - 1;
else if (r >= KEYBOARD_ROWS)
r = 0;
if (c < 0)
c = KEYBOARD_COLS - 1;
else if (c >= KEYBOARD_COLS)
c = 0;
cursorRow = (uint8_t)r;
cursorCol = (uint8_t)c;
}
void VirtualKeyboard::moveCursorUp()
{
moveCursorDelta(-1, 0);
}
void VirtualKeyboard::moveCursorDown()
{
moveCursorDelta(1, 0);
}
void VirtualKeyboard::moveCursorLeft()
{
resetTimeout();
if (cursorCol > 0) {
cursorCol--;
} else {
if (cursorRow > 0) {
cursorRow--;
cursorCol = KEYBOARD_COLS - 1;
} else {
cursorRow = KEYBOARD_ROWS - 1;
cursorCol = KEYBOARD_COLS - 1;
}
}
}
void VirtualKeyboard::moveCursorRight()
{
resetTimeout();
if (cursorCol < KEYBOARD_COLS - 1) {
cursorCol++;
} else {
if (cursorRow < KEYBOARD_ROWS - 1) {
cursorRow++;
cursorCol = 0;
} else {
cursorRow = 0;
cursorCol = 0;
}
}
}
void VirtualKeyboard::handlePress()
{
resetTimeout(); // Reset timeout on any input activity
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;
case VK_SPACE:
insertCharacter(' ');
break;
case VK_ESC:
if (onTextEntered) {
std::function<void(const std::string &)> callback = onTextEntered;
onTextEntered = nullptr;
inputText = "";
callback("");
}
return;
default:
break;
}
}
void VirtualKeyboard::handleLongPress()
{
resetTimeout(); // Reset timeout on any input activity
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;
}
switch (key.type) {
case VK_BACKSPACE:
// One-shot: delete up to 5 characters on long press
for (int i = 0; i < 5; ++i) {
if (inputText.empty())
break;
deleteCharacter();
}
break;
case VK_ENTER:
submitText();
break;
case VK_SPACE:
insertCharacter(' ');
break;
case VK_ESC:
if (onTextEntered) {
onTextEntered("");
}
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<void(const std::string &)> 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<void(const std::string &)> callback)
{
onTextEntered = callback;
}
void VirtualKeyboard::resetTimeout()
{
lastActivityTime = millis();
}
bool VirtualKeyboard::isTimedOut() const
{
return (millis() - lastActivityTime) > TIMEOUT_MS;
}
} // namespace graphics

View File

@ -0,0 +1,80 @@
#pragma once
#include "configuration.h"
#include <OLEDDisplay.h>
#include <functional>
#include <string>
namespace graphics
{
enum VirtualKeyType { VK_CHAR, VK_BACKSPACE, VK_ENTER, VK_SHIFT, VK_ESC, VK_SPACE };
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<void(const std::string &)> 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;
private:
static const uint8_t KEYBOARD_ROWS = 4;
static const uint8_t KEYBOARD_COLS = 11;
static const uint8_t KEY_WIDTH = 9;
static const uint8_t KEY_HEIGHT = 9; // Compressed to fit 4 rows on 64px displays
static const uint8_t KEYBOARD_START_Y = 26; // Start just below input box bottom
VirtualKey keyboard[KEYBOARD_ROWS][KEYBOARD_COLS];
std::string inputText;
std::string headerText;
std::function<void(const std::string &)> onTextEntered;
uint8_t cursorRow;
uint8_t cursorCol;
// 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 x, int16_t y, uint8_t w, uint8_t h,
bool isLastCol);
void drawInputArea(OLEDDisplay *display, int16_t offsetX, int16_t offsetY, int16_t keyboardStartY);
// Unified cursor movement helper
void moveCursorDelta(int dRow, int dCol);
char getCharForKey(const VirtualKey &key, bool isLongPress = false);
void insertCharacter(char c);
void deleteCharacter();
void submitText();
};
} // namespace graphics

View File

@ -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"

View File

@ -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<void(const std::string &)> NotificationRenderer::textInputCallback = nullptr;
uint32_t pow_of_10(uint32_t n)
{
@ -89,14 +91,33 @@ 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
break;
case notificationTypeEnum::text_input:
// Already handled above with dedicated logic (early return). Keep a case here to satisfy -Wswitch.
break;
case notificationTypeEnum::text_banner:
case notificationTypeEnum::selection_picker:
drawAlertBannerOverlay(display, state);
@ -575,6 +596,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);

View File

@ -3,6 +3,9 @@
#include "OLEDDisplay.h"
#include "OLEDDisplayUi.h"
#include "graphics/Screen.h"
#include "graphics/VirtualKeyboard.h"
#include <functional>
#include <string>
#define MAX_LINES 5
namespace graphics
@ -22,6 +25,8 @@ class NotificationRenderer
static std::function<void(int)> alertBannerCallback;
static uint32_t numDigits;
static uint32_t currentNumber;
static VirtualKeyboard *virtualKeyboard;
static std::function<void(const std::string &)> 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);

View File

@ -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,

View File

@ -1,12 +1,14 @@
#include "TrackballInterruptBase.h"
#include "configuration.h"
extern bool osk_found;
TrackballInterruptBase::TrackballInterruptBase(const char *name) : concurrency::OSThread(name), _originName(name) {}
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 +20,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);
@ -40,9 +43,9 @@ 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);
osk_found = true;
this->setInterval(100);
}
@ -50,10 +53,47 @@ 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;
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;
}
}
#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 +108,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 +133,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()

View File

@ -18,8 +18,8 @@ class TrackballInterruptBase : public Observable<const InputEvent *>, 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<const InputEvent *>, 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,20 @@ class TrackballInterruptBase : public Observable<const InputEvent *>, public con
volatile TrackballInterruptBaseActionType action = TB_ACTION_NONE;
// Long press detection for press button
uint32_t pressStartTime = 0;
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
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;
};

View File

@ -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);
}

View File

@ -191,6 +191,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;
@ -1412,6 +1414,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();

View File

@ -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;

View File

@ -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
@ -38,6 +42,7 @@
extern ScanI2C::DeviceAddress cardkb_found;
extern bool graphics::isMuted;
extern bool osk_found;
static const char *cannedMessagesConfigFile = "/prefs/cannedConf.proto";
static NodeNum lastDest = NODENUM_BROADCAST;
@ -151,10 +156,13 @@ 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 keyboard
// Add a "Free Text" entry at the top if using a touch screen virtual keyboard
tempMessages[tempCount++] = "[-- Free Text --]";
#else
if (osk_found && screen) {
tempMessages[tempCount++] = "[-- Free Text --]";
}
#endif
// First message always starts at buffer start
@ -341,6 +349,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;
@ -627,6 +637,56 @@ bool CannedMessageModule::handleMessageSelectorInput(const InputEvent *event, bo
notifyObservers(&e);
return true;
}
#else
if (strcmp(current, "[-- Free Text --]") == 0) {
if (osk_found && screen) {
char headerBuffer[64];
if (this->dest == NODENUM_BROADCAST) {
snprintf(headerBuffer, sizeof(headerBuffer), "To: Broadcast@%s", channels.getName(this->channel));
} else {
snprintf(headerBuffer, sizeof(headerBuffer), "To: %s", getNodeName(this->dest));
}
screen->showTextInput(headerBuffer, "", 300000, [this](const std::string &text) {
if (!text.empty()) {
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);
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
@ -943,12 +1003,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;
@ -958,6 +1060,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
@ -966,9 +1080,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;