#include "KeyboardEntryActivity.h" #include #include #include #include #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_KEY = 1; // --------------------------------------------------------------------------- // URL layers. The SDK builtin layouts have no URL variant (":", "/", ".", the // snippet panel), so these are app-defined tables over the same public // KeyboardLayout structs. URLs are ASCII, so the letter rows are EN-arranged // regardless of UI language. // --------------------------------------------------------------------------- #define UK(label, output, value) \ fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, nullptr } #define UKA(label, output, value, alt) \ fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, alt } #define UKW(label, output, value, units) \ fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units, true, nullptr } #define UKS(label, kind, value, units) \ fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units, true, nullptr } constexpr int16_t URL_PANEL_VALUE = -3; // mirrors KeyboardEntryActivity::URL_PANEL_KEY const fui::KeyboardKey URL_NUM_ROW[] = {UKA("1", "1", '1', "!"), UKA("2", "2", '2', "@"), UKA("3", "3", '3', "#"), UKA("4", "4", '4', "$"), UKA("5", "5", '5', "%"), UKA("6", "6", '6', "^"), UKA("7", "7", '7', "&"), UKA("8", "8", '8', "*"), UKA("9", "9", '9', "("), UKA("0", "0", '0', ")")}; const fui::KeyboardKey URL_ROW1[] = {UK("q", "q", 'q'), UK("w", "w", 'w'), UK("e", "e", 'e'), UK("r", "r", 'r'), UK("t", "t", 't'), UK("y", "y", 'y'), UK("u", "u", 'u'), UK("i", "i", 'i'), UK("o", "o", 'o'), UK("p", "p", 'p')}; const fui::KeyboardKey URL_ROW2[] = {UK("a", "a", 'a'), UK("s", "s", 's'), UK("d", "d", 'd'), UK("f", "f", 'f'), UK("g", "g", 'g'), UK("h", "h", 'h'), UK("j", "j", 'j'), UK("k", "k", 'k'), UK("l", "l", 'l')}; const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), UK("z", "z", 'z'), UK("x", "x", 'x'), UK("c", "c", 'c'), UK("v", "v", 'v'), UK("b", "b", 'b'), UK("n", "n", 'n'), UK("m", "m", 'm'), UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; // URLs have no spaces, so the URL bottom row spends the space slot on ":", // "/", "." and the snippet-panel toggle instead (the legacy keyboard did the // same with its "URL" key). const fui::KeyboardKey URL_BOTTOM[] = {UKS("?123", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), UK(":", ":", ':'), UK("/", "/", '/'), UK(".", ".", '.'), UKW("URL", nullptr, URL_PANEL_VALUE, 2), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), UK("E", "E", 'E'), UK("R", "R", 'R'), UK("T", "T", 'T'), UK("Y", "Y", 'Y'), UK("U", "U", 'U'), UK("I", "I", 'I'), UK("O", "O", 'O'), UK("P", "P", 'P')}; const fui::KeyboardKey URL_SHIFT_ROW2[] = {UK("A", "A", 'A'), UK("S", "S", 'S'), UK("D", "D", 'D'), UK("F", "F", 'F'), UK("G", "G", 'G'), UK("H", "H", 'H'), UK("J", "J", 'J'), UK("K", "K", 'K'), UK("L", "L", 'L')}; const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), UK("Z", "Z", 'Z'), UK("X", "X", 'X'), UK("C", "C", 'C'), UK("V", "V", 'V'), UK("B", "B", 'B'), UK("N", "N", 'N'), UK("M", "M", 'M'), UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; // Snippet keys: multi-character outputs, stable ids above the localized-key // range so they never collide with layout key ids. const fui::KeyboardKey URL_SNIP_ROW1[] = {UK("https://", "https://", 2001), UK("www.", "www.", 2002), UK(".com", ".com", 2003)}; const fui::KeyboardKey URL_SNIP_ROW2[] = {UK("http://", "http://", 2004), UK("192.168.", "192.168.", 2005), UK(".org", ".org", 2006)}; const fui::KeyboardKey URL_SNIP_ROW3[] = {UK("/opds", "/opds", 2007), UK(":8080", ":8080", 2008), UK(".net", ".net", 2009)}; const fui::KeyboardKey URL_SNIP_BOTTOM[] = {UKS("abc", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), UKW("URL", nullptr, URL_PANEL_VALUE, 2), UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; #undef UK #undef UKA #undef UKW #undef UKS const fui::KeyboardRow URL_ROWS[] = { {URL_NUM_ROW, 10, 0}, {URL_ROW1, 10, 0}, {URL_ROW2, 9, 1}, {URL_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; const fui::KeyboardRow URL_SHIFT_ROWS[] = { {URL_NUM_ROW, 10, 0}, {URL_SHIFT_ROW1, 10, 0}, {URL_SHIFT_ROW2, 9, 1}, {URL_SHIFT_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; const fui::KeyboardRow URL_SNIP_ROWS[] = { {URL_SNIP_ROW1, 3, 0}, {URL_SNIP_ROW2, 3, 0}, {URL_SNIP_ROW3, 3, 0}, {URL_SNIP_BOTTOM, 4, 0}}; const fui::KeyboardLayout URL_LAYOUT{URL_ROWS, 5}; const fui::KeyboardLayout URL_SHIFT_LAYOUT{URL_SHIFT_ROWS, 5}; const fui::KeyboardLayout URL_SNIPPET_LAYOUT{URL_SNIP_ROWS, 4}; fui::KeyboardLayoutId layoutForLanguage(const Language language) { switch (language) { case Language::FR: return fui::KeyboardLayoutId::AzertyFr; case Language::DE: return fui::KeyboardLayoutId::QwertzDe; case Language::ES: return fui::KeyboardLayoutId::SpanishEs; default: return fui::KeyboardLayoutId::QwertyEn; } } } // namespace void KeyboardEntryActivity::onEnter() { Activity::onEnter(); cursorPos = text.length(); // URL layers are EN-arranged app tables; everything else follows the UI // language. layoutId = inputType == InputType::Url ? fui::KeyboardLayoutId::QwertyEn : layoutForLanguage(I18N.getLanguage()); shifted = false; symbols = false; urlPanel = false; cursorMode = false; togglePos = false; passwordVisible = false; selRow = 0; selCol = 0; delPressCount = 0; hintVisible = false; hintShowTime = 0; rightHeld = false; rightLongHandled = false; savedCursorPos = 0; rightStartCursorPos = 0; touchRouter.reset(); touchRouter.holdMs = TOUCH_LONG_PRESS_MS; touchRouter.overrideHoldMs = TOUCH_DEL_LONG_PRESS_MS; interactionsReady = false; requestUpdate(); } void KeyboardEntryActivity::onExit() { Activity::onExit(); } const fui::KeyboardLayout& KeyboardEntryActivity::currentLayout() const { if (symbols) return fui::builtinKeyboardLayout(layoutId, shifted, true); if (inputType == InputType::Url) { if (urlPanel) return URL_SNIPPET_LAYOUT; return shifted ? URL_SHIFT_LAYOUT : URL_LAYOUT; } return fui::builtinKeyboardLayout(layoutId, shifted, false, /*numberRow=*/true); } const fui::KeyboardKey* KeyboardEntryActivity::selectedKey() const { const fui::KeyboardLayout& layout = currentLayout(); if (selRow < 0 || selRow >= layout.rowCount) return nullptr; const fui::KeyboardRow& row = layout.rows[selRow]; if (selCol < 0 || selCol >= row.count) return nullptr; return &row.keys[selCol]; } int KeyboardEntryActivity::selectedLogicalIndex() const { const fui::KeyboardLayout& layout = currentLayout(); int index = 0; for (int r = 0; r < selRow && r < layout.rowCount; r++) { index += layout.rows[r].count; } return index + selCol; } void KeyboardEntryActivity::clampSelection() { const fui::KeyboardLayout& layout = currentLayout(); if (layout.rowCount == 0) { selRow = 0; selCol = 0; return; } if (selRow < 0) selRow = 0; if (selRow >= layout.rowCount) selRow = layout.rowCount - 1; const int cols = layout.rows[selRow].count; if (selCol < 0) selCol = 0; if (selCol >= cols) selCol = cols > 0 ? cols - 1 : 0; } void KeyboardEntryActivity::moveSelectionRow(const int delta) { const fui::KeyboardLayout& layout = currentLayout(); if (layout.rowCount == 0) return; const int oldCols = selRow < layout.rowCount ? layout.rows[selRow].count : 1; selRow = (selRow + delta + layout.rowCount) % layout.rowCount; const int newCols = layout.rows[selRow].count; // Proportional column mapping keeps vertical travel intuitive between rows // of different key counts (e.g. a 10-key letter row over a 6-key bottom row). if (oldCols > 0 && newCols > 0 && oldCols != newCols) { selCol = selCol * newCols / oldCols; } clampSelection(); } void KeyboardEntryActivity::moveSelectionCol(const int delta) { const fui::KeyboardLayout& layout = currentLayout(); if (selRow < 0 || selRow >= layout.rowCount) return; const int cols = layout.rows[selRow].count; if (cols <= 0) return; selCol = (selCol + delta + cols) % cols; } bool KeyboardEntryActivity::syncSelectionToValue(const int16_t value) { const fui::KeyboardLayout& layout = currentLayout(); for (int r = 0; r < layout.rowCount; r++) { for (int c = 0; c < layout.rows[r].count; c++) { if (layout.rows[r].keys[c].value == value) { selRow = r; selCol = c; return true; } } } return false; } size_t KeyboardEntryActivity::utf8Prev(const std::string& s, size_t pos) { if (pos == 0) return 0; pos--; while (pos > 0 && (static_cast(s[pos]) & 0xC0) == 0x80) pos--; return pos; } size_t KeyboardEntryActivity::utf8Next(const std::string& s, size_t pos) { if (pos >= s.length()) return s.length(); pos++; while (pos < s.length() && (static_cast(s[pos]) & 0xC0) == 0x80) pos++; return pos; } void KeyboardEntryActivity::insertUtf8(const char* out) { if (!out || !*out) return; const size_t n = strlen(out); if (maxLength != 0 && text.length() + n > maxLength) return; if (cursorPos > text.length()) cursorPos = text.length(); text.insert(cursorPos, out, n); cursorPos += n; } bool KeyboardEntryActivity::backspaceUtf8() { if (text.empty() || cursorPos == 0) return false; const size_t prev = utf8Prev(text, cursorPos); text.erase(prev, cursorPos - prev); cursorPos = prev; return true; } bool KeyboardEntryActivity::activateValue(const int16_t value, const bool longPress) { switch (value) { case fui::QWERTY_KEY_SHIFT: delPressCount = 0; hintVisible = false; // Letters: case toggle. Symbols: pages between "?123" and "#+=". shifted = !shifted; clampSelection(); return true; case fui::QWERTY_KEY_MODE: delPressCount = 0; hintVisible = false; if (urlPanel) { urlPanel = false; } else { symbols = !symbols; shifted = false; } clampSelection(); return true; case URL_PANEL_KEY: delPressCount = 0; hintVisible = false; urlPanel = !urlPanel; symbols = false; shifted = false; clampSelection(); return true; case fui::QWERTY_KEY_ENTER: onComplete(text); return false; case fui::QWERTY_KEY_BACKSPACE: if (longPress) { text.clear(); cursorPos = 0; return true; } delPressCount++; if (delPressCount >= 2) { hintVisible = true; hintShowTime = millis(); } backspaceUtf8(); return true; default: { delPressCount = 0; hintVisible = false; const fui::KeyboardLayout& layer = currentLayout(); // keyboardAltOutputFor covers explicit alts and the letter case-flip. const char* out = longPress ? fui::keyboardAltOutputFor(layer, value) : nullptr; if (!out) out = fui::keyboardOutputFor(layer, value); if (!out) return false; insertUtf8(out); if (shifted && !symbols) { shifted = false; // shift auto-releases after one character clampSelection(); } return true; } } } bool KeyboardEntryActivity::clearAllOrAltOnSelected() { const fui::KeyboardKey* key = selectedKey(); if (!key) return false; if (key->value == fui::QWERTY_KEY_BACKSPACE) { text.clear(); cursorPos = 0; return true; } // Explicit alts and the letter case-flip, same as touch long-press. const char* alt = fui::keyboardAltOutputFor(currentLayout(), key->value); if (alt) { insertUtf8(alt); return true; } return false; } std::string KeyboardEntryActivity::displayTextForCurrentState() const { std::string displayText = text; if (inputType != InputType::Password || passwordVisible) { return displayText; } size_t revealPos; if (cursorMode) { revealPos = text.length(); // no reveal in displayText; block draws actual char directly } else { revealPos = (text.length() > 0 && cursorPos > 0) ? cursorPos - 1 : std::string::npos; } for (size_t i = 0; i < displayText.length(); i++) { if (i != revealPos) { displayText[i] = '*'; } } return displayText; } int KeyboardEntryActivity::measureRange(std::string& s, const int start, const int end) const { if (end <= start) return 0; // s[end] is writable even at s.length() (the terminator slot); only '\0' may // be written there, which is exactly what the measurement needs. const char saved = s[end]; s[end] = '\0'; const int width = renderer.getTextAdvanceX(UI_12_FONT_ID, s.c_str() + start, EpdFontFamily::REGULAR); s[end] = saved; return width; } int KeyboardEntryActivity::lineBreakEnd(std::string& s, const int start, const int maxWidth) const { const int len = static_cast(s.length()); if (measureRange(s, start, len) <= maxWidth) return len; int lo = start + 1; int hi = len - 1; int best = start + 1; while (lo <= hi) { const int mid = lo + (hi - lo) / 2; if (measureRange(s, start, mid) <= maxWidth) { best = mid; lo = mid + 1; } else { hi = mid - 1; } } return best; } bool KeyboardEntryActivity::cursorPositionFromPoint(const int x, const int y, size_t& position) const { // Key taps are the overwhelmingly common case; they land on the keyboard, // never the text field, so skip the wrap/measure work entirely. if (y >= keyboardRect().y) return false; const int pageWidth = renderer.getScreenWidth(); const auto& metrics = UITheme::getInstance().getMetrics(); const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int inputStartY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; int availableWidth = pageWidth; if (gpio.deviceIsX3()) { availableWidth -= 2 * metrics.sideButtonHintsWidth; } const int effectiveMargin = (pageWidth - availableWidth * metrics.keyboardTextFieldWidthPercent / 100) / 2; const int toggleGap = inputType == InputType::Password ? 4 : 0; const int toggleReserve = inputType == InputType::Password ? std::max(renderer.getTextWidth(UI_12_FONT_ID, "[abc]"), renderer.getTextWidth(UI_12_FONT_ID, "[***]")) + toggleGap : 0; const int textAreaWidth = pageWidth - 2 * effectiveMargin - toggleReserve; const int maxLineWidth = textAreaWidth; const bool centerText = metrics.keyboardCenteredText; std::string displayText = displayTextForCurrentState(); int lineStartIdx = 0; int lineY = inputStartY; int lastLineStartIdx = 0; int lastLineEndIdx = static_cast(displayText.length()); int lastLineStartX = effectiveMargin; int lastLineWidth = 0; while (true) { const int lineEndIdx = lineBreakEnd(displayText, lineStartIdx, maxLineWidth); const int textWidth = measureRange(displayText, lineStartIdx, lineEndIdx); const int lineStartX = centerText ? effectiveMargin + (maxLineWidth - textWidth) / 2 : effectiveMargin; lastLineStartIdx = lineStartIdx; lastLineEndIdx = lineEndIdx; lastLineStartX = lineStartX; lastLineWidth = textWidth; if (y >= lineY - metrics.verticalSpacing && y < lineY + lineHeight + metrics.verticalSpacing) { if (x <= lineStartX) { position = static_cast(lineStartIdx); return true; } if (x >= lineStartX + textWidth) { position = static_cast(lineEndIdx); return true; } int previousWidth = 0; for (int i = lineStartIdx; i < lineEndIdx; i++) { const int nextWidth = measureRange(displayText, lineStartIdx, i + 1); const int midpoint = lineStartX + previousWidth + (nextWidth - previousWidth) / 2; if (x < midpoint) { position = static_cast(i); return true; } previousWidth = nextWidth; } position = static_cast(lineEndIdx); return true; } if (lineEndIdx == static_cast(displayText.length())) { break; } lineY += lineHeight; lineStartIdx = lineEndIdx; } const int underlineBottom = lineY + lineHeight + metrics.verticalSpacing + 8; if (y >= inputStartY - metrics.verticalSpacing && y < underlineBottom && x >= effectiveMargin && x < effectiveMargin + maxLineWidth + toggleReserve) { position = x < lastLineStartX + lastLineWidth ? static_cast(lastLineStartIdx) : static_cast(lastLineEndIdx); return true; } return false; } fui::Rect KeyboardEntryActivity::keyboardRect() const { const auto& metrics = UITheme::getInstance().getMetrics(); const int pageWidth = renderer.getScreenWidth(); const int pageHeight = renderer.getScreenHeight(); const int rows = currentLayout().rowCount; const int gap = metrics.keyboardKeySpacing; const int height = rows * metrics.keyboardKeyHeight + (rows > 1 ? (rows - 1) * gap : 0); const int width = pageWidth * metrics.keyboardWidthPercent / 100; const int x = (pageWidth - width) / 2; const int y = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - height + metrics.keyboardVerticalOffset; return fui::Rect{static_cast(x), static_cast(y), static_cast(width), static_cast(height)}; } void KeyboardEntryActivity::loop() { int tx = 0; int ty = 0; size_t touchedCursorPos = 0; if (mappedInput.wasScreenTapped(tx, ty) && cursorPositionFromPoint(tx, ty, touchedCursorPos)) { cursorPos = std::min(touchedCursorPos, text.length()); // The masked text field maps taps per byte; snap back to a boundary so // the cursor never lands inside a multi-byte character. while (cursorPos > 0 && cursorPos < text.length() && (static_cast(text[cursorPos]) & 0xC0) == 0x80) { cursorPos--; } cursorMode = false; togglePos = false; hintVisible = false; touchRouter.reset(); requestUpdate(); return; } if (!cursorMode && interactionsReady) { const bool pressedDown = mappedInput.wasScreenTouchDown(tx, ty); int tapX = 0; int tapY = 0; const bool tapped = mappedInput.wasScreenTapped(tapX, tapY); int hx = 0; int hy = 0; const bool inContact = mappedInput.isScreenTouchHeld(hx, hy); const fui::TouchHoldRouter::Result result = touchRouter.update(interactions, pressedDown, static_cast(tx), static_cast(ty), tapped, static_cast(tapX), static_cast(tapY), inContact, millis()); if (result.event) { syncSelectionToValue(result.event.value); if (activateValue(result.event.value, result.event.longPress)) { requestUpdate(); } return; } if (result.activeChanged) { requestUpdate(); } if (pressedDown || tapped) { return; } } if (!cursorMode && mappedInput.wasPressed(MappedInputManager::Button::Up)) { upHeld = true; upLongHandled = false; } if (upHeld && !upLongHandled && mappedInput.isPressed(MappedInputManager::Button::Up) && mappedInput.getHeldTime() > LONG_PRESS_MS) { cursorMode = true; upLongHandled = true; hintVisible = true; hintShowTime = millis(); requestUpdate(); } if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { if (upHeld && !upLongHandled && !cursorMode) { moveSelectionRow(-1); requestUpdate(); } upHeld = false; upLongHandled = false; } if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { downHeld = true; if (cursorMode) { togglePos = false; passwordVisible = false; cursorMode = false; hintVisible = false; downLongHandled = true; requestUpdate(); } else { downLongHandled = false; } } if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { if (downHeld && !downLongHandled && !cursorMode) { moveSelectionRow(1); requestUpdate(); } downHeld = false; downLongHandled = false; } buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { if (cursorMode) return; moveSelectionCol(-1); requestUpdate(); }); if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (cursorMode) { if (togglePos) { cursorPos = savedCursorPos; togglePos = false; requestUpdate(); } else if (cursorPos > 0) { cursorPos = utf8Prev(text, cursorPos); requestUpdate(); } } } if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { if (cursorMode && inputType == InputType::Password && !togglePos) { rightHeld = true; rightLongHandled = false; rightStartCursorPos = cursorPos; } } buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { if (cursorMode) return; moveSelectionCol(1); requestUpdate(); }); if (rightHeld && !rightLongHandled && mappedInput.isPressed(MappedInputManager::Button::Right) && mappedInput.getHeldTime() > LONG_PRESS_MS) { if (cursorMode && inputType == InputType::Password && !togglePos) { savedCursorPos = rightStartCursorPos; togglePos = true; rightLongHandled = true; requestUpdate(); } } if (mappedInput.wasReleased(MappedInputManager::Button::Right)) { if (cursorMode && inputType == InputType::Password) { rightHeld = false; rightLongHandled = false; } if (cursorMode && !togglePos && cursorPos < text.length()) { cursorPos = utf8Next(text, cursorPos); requestUpdate(); } if (cursorMode) return; rightHeld = false; rightLongHandled = false; } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { confirmHeld = true; confirmLongHandled = false; } const fui::KeyboardKey* selKey = selectedKey(); const bool selectedDel = selKey && selKey->value == fui::QWERTY_KEY_BACKSPACE; if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > DEL_LONG_PRESS_MS && selectedDel) { clearAllOrAltOnSelected(); confirmLongHandled = true; requestUpdate(); } if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() > LONG_PRESS_MS) { if (!selectedDel && clearAllOrAltOnSelected()) { requestUpdate(); confirmLongHandled = true; } } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (confirmHeld && !confirmLongHandled && !cursorMode) { if (selKey && activateValue(selKey->value, false)) { requestUpdate(); } } else if (confirmHeld && !confirmLongHandled && cursorMode && inputType == InputType::Password && togglePos) { passwordVisible = !passwordVisible; requestUpdate(); } confirmHeld = false; confirmLongHandled = false; } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onCancel(); } if (hintVisible && !cursorMode && millis() - hintShowTime > 4000) { hintVisible = false; requestUpdate(); } } void KeyboardEntryActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto& metrics = UITheme::getInstance().getMetrics(); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str()); const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int inputStartY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; int inputHeight = 0; std::string displayText = displayTextForCurrentState(); const bool isPassword = (inputType == InputType::Password); int availableWidth = pageWidth; if (gpio.deviceIsX3()) { availableWidth -= 2 * metrics.sideButtonHintsWidth; } const int effectiveMargin = (pageWidth - availableWidth * metrics.keyboardTextFieldWidthPercent / 100) / 2; const int toggleGap = isPassword ? 4 : 0; const int toggleReserve = isPassword ? std::max(renderer.getTextWidth(UI_12_FONT_ID, "[abc]"), renderer.getTextWidth(UI_12_FONT_ID, "[***]")) + toggleGap : 0; const int textAreaWidth = pageWidth - 2 * effectiveMargin - toggleReserve; const int maxLineWidth = textAreaWidth; const bool centerText = metrics.keyboardCenteredText; int cursorCharWidth = 6; if (cursorPos < text.length()) { int w = renderer.getTextWidth(UI_12_FONT_ID, text.substr(cursorPos, 1).c_str()); if (w > cursorCharWidth) cursorCharWidth = w; } int lineStartIdx = 0; int textWidth = 0; int cursorPixelX = effectiveMargin; int cursorLineY = inputStartY; bool cursorDrawn = false; while (true) { const int lineEndIdx = lineBreakEnd(displayText, lineStartIdx, maxLineWidth); const std::string lineText = displayText.substr(lineStartIdx, lineEndIdx - lineStartIdx); textWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, lineText.c_str(), EpdFontFamily::REGULAR); { const bool isLastLine = (lineEndIdx == static_cast(displayText.length())); bool isCursorLine = false; if (!cursorDrawn && cursorPos >= lineStartIdx && (isLastLine ? cursorPos <= lineEndIdx : cursorPos < lineEndIdx)) { std::string beforeCursor; if (isPassword && !passwordVisible && cursorMode) { beforeCursor = std::string(cursorPos - lineStartIdx, '*'); } else { beforeCursor = displayText.substr(lineStartIdx, cursorPos - lineStartIdx); } int beforeWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, beforeCursor.c_str(), EpdFontFamily::REGULAR); int kernOffset = 0; if (cursorPos < displayText.length()) { std::string beforeAndCursor = beforeCursor + displayText.substr(cursorPos, 1); int beforeAndCursorWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, beforeAndCursor.c_str(), EpdFontFamily::REGULAR); int charAdvance = renderer.getTextAdvanceX(UI_12_FONT_ID, displayText.substr(cursorPos, 1).c_str(), EpdFontFamily::REGULAR); kernOffset = beforeAndCursorWidth - beforeWidth - charAdvance; } if (centerText) { cursorPixelX = effectiveMargin + (maxLineWidth - textWidth) / 2 + beforeWidth + kernOffset; } else { cursorPixelX = effectiveMargin + beforeWidth + kernOffset; } cursorLineY = inputStartY + inputHeight; cursorDrawn = true; isCursorLine = true; } const int lineStartX = centerText ? effectiveMargin + (maxLineWidth - textWidth) / 2 : effectiveMargin; if (isCursorLine && cursorMode && isPassword && !passwordVisible && !togglePos) { // Draw text in 3 parts to avoid block cursor overflowing onto next char. // displayText uses '*' for all chars; actual char may be wider than '*'. // Part 1: chars before cursor position const std::string part1 = displayText.substr(lineStartIdx, cursorPos - lineStartIdx); renderer.drawText(UI_12_FONT_ID, lineStartX, inputStartY + inputHeight, part1.c_str()); // Part 2: skip cursor slot (block + actual char drawn later) // Part 3: chars after cursor position (skip char under cursor), starting at cursorPixelX + cursorCharWidth const int afterStart = static_cast(cursorPos) + (cursorPos < text.length() ? 1 : 0); const int afterEnd = lineEndIdx; if (afterStart < afterEnd) { const std::string part3 = displayText.substr(afterStart, afterEnd - afterStart); renderer.drawText(UI_12_FONT_ID, cursorPixelX + cursorCharWidth, inputStartY + inputHeight, part3.c_str()); } } else { renderer.drawText(UI_12_FONT_ID, lineStartX, inputStartY + inputHeight, lineText.c_str()); } if (lineEndIdx == static_cast(displayText.length())) { break; } inputHeight += lineHeight; lineStartIdx = lineEndIdx; } } const int fieldWidth = (inputHeight > 0) ? maxLineWidth : textWidth; const int lineMargin = effectiveMargin; GUI.drawTextField(renderer, Rect{0, inputStartY, pageWidth, inputHeight}, fieldWidth, cursorMode, lineMargin, pageWidth - 2 * lineMargin); if (cursorMode && !togglePos && cursorPos <= displayText.length()) { static constexpr int blockPadding = 1; renderer.fillRect(cursorPixelX - blockPadding, cursorLineY, cursorCharWidth + blockPadding * 2, lineHeight, true); if (cursorPos < text.length()) { const char buf[2] = {text[cursorPos], '\0'}; renderer.drawText(UI_12_FONT_ID, cursorPixelX, cursorLineY, buf, false); } } else if (cursorPos <= displayText.length()) { static constexpr int serifW = 3; const int cX = cursorPixelX; const int cY = cursorLineY; const int cBottom = cursorLineY + lineHeight - 1; renderer.fillRect(cX, cY, 2, lineHeight, true); renderer.drawLine(cX - serifW, cY, cX - 1, cY, 2, true); renderer.drawLine(cX + 1, cY, cX + serifW, cY, 2, true); renderer.drawLine(cX - serifW, cBottom, cX - 1, cBottom, 2, true); renderer.drawLine(cX + 1, cBottom, cX + serifW, cBottom, 2, true); } if (isPassword) { const char* toggleLabel = passwordVisible ? "[***]" : "[abc]"; const int toggleWidth = renderer.getTextWidth(UI_12_FONT_ID, toggleLabel); const int toggleX = pageWidth - effectiveMargin - toggleWidth; const int toggleY = inputStartY + inputHeight; const bool toggleSelected = cursorMode && togglePos; if (toggleSelected) { renderer.fillRect(toggleX - 2, toggleY, toggleWidth + 5, lineHeight + 3, true); renderer.drawText(UI_12_FONT_ID, toggleX, toggleY, toggleLabel, false); } else { renderer.drawText(UI_12_FONT_ID, toggleX, toggleY, toggleLabel, true); } } if (hintVisible && !text.empty()) { const int hintLh = renderer.getLineHeight(SMALL_FONT_ID); const int underlineY = inputStartY + inputHeight + lineHeight + metrics.verticalSpacing; const int hintY = underlineY + 4; if (cursorMode) { int hintLineY = hintY; if (inputType == InputType::Password && togglePos) { renderer.drawCenteredText( SMALL_FONT_ID, hintLineY, passwordVisible ? tr(STR_KB_HINT_TOGGLE_HIDE_PASSWORD) : tr(STR_KB_HINT_TOGGLE_SHOW_PASSWORD), true); hintLineY += hintLh; renderer.drawCenteredText(SMALL_FONT_ID, hintLineY, tr(STR_KB_HINT_RETURN_CURSOR), true); } else { renderer.drawCenteredText(SMALL_FONT_ID, hintLineY, tr(STR_KB_HINT_MOVE_CURSOR), true); hintLineY += hintLh; if (inputType == InputType::Password) { const char* passTip = passwordVisible ? tr(STR_KB_HINT_HIDE_PASSWORD) : tr(STR_KB_HINT_SHOW_PASSWORD); renderer.drawCenteredText(SMALL_FONT_ID, hintLineY, passTip, true); } } } else { renderer.drawCenteredText(SMALL_FONT_ID, hintY, tr(STR_KB_HINT_EDIT_ENTRY), true); } } const fui::Rect kbRect = keyboardRect(); const int tipsLh = renderer.getLineHeight(SMALL_FONT_ID); const int underlineBottom = inputStartY + inputHeight + lineHeight + metrics.verticalSpacing + 4; auto drawTip = [&](const char* tip, int y) { renderer.drawCenteredText(SMALL_FONT_ID, y, tip, true); }; int tipCount = 0; if (cursorMode) { tipCount = 1; } else if (urlPanel) { tipCount = 1 + (!text.empty() ? 1 : 0); } else if (symbols) { tipCount = !text.empty() ? 1 : 0; } else { tipCount = 1 + (inputType == InputType::Url ? 1 : 0) + (!text.empty() ? 1 : 0); } if (tipCount > 0) { int y = (underlineBottom + kbRect.y) / 2 - (tipCount + 1) * tipsLh / 2; drawTip(tr(STR_KB_TIPS), y); y += tipsLh; if (cursorMode) { drawTip(tr(STR_KB_HINT_RETURN_KEYBOARD), y); } else if (urlPanel) { drawTip(tr(STR_KB_HINT_EXIT_URL_MODE), y); y += tipsLh; if (!text.empty()) { drawTip(tr(STR_KB_HINT_CLEAR_TEXT), y); } } else if (symbols) { if (!text.empty()) { drawTip(tr(STR_KB_HINT_CLEAR_TEXT), y); } } else { const char* altCharTip; if (inputType == InputType::Url) { altCharTip = tr(STR_KB_HINT_SECONDARY_CHAR); } else if (shifted) { altCharTip = tr(STR_KB_HINT_LOWER_SECONDARY); } else { altCharTip = tr(STR_KB_HINT_UPPER_SECONDARY); } drawTip(altCharTip, y); y += tipsLh; if (inputType == InputType::Url) { drawTip(tr(STR_KB_HINT_URL_SNIPPETS), y); y += tipsLh; } if (!text.empty()) { drawTip(tr(STR_KB_HINT_CLEAR_TEXT), y); } } } // The FreeInkUI keyboard draws the keys and registers their hit rects into // `interactions`; loop() routes touch snapshots against that table. interactionsReady = false; fui::GfxRendererTarget target(renderer); target.setFont(fui::GfxRendererTarget::FONT_SMALL, SMALL_FONT_ID); target.setFont(fui::GfxRendererTarget::FONT_BODY, UI_12_FONT_ID); const fui::DeviceContext device = target.deviceContext(); const fui::InputSnapshot noInput{}; fui::Frame<48> frame(target, device, noInput, interactions); fui::KeyboardProps props; const fui::KeyboardLayout& layout = currentLayout(); props.layout = &layout; props.keyAction = ACTION_KEY; // one action id; loop() dispatches on key value props.okLabel = tr(STR_OK_BUTTON); props.shiftLabel = tr(STR_KEY_SHIFT); // Match the label to the layer the mode key leads back from: the symbols // layer and the URL snippet panel both label it "abc" in the static tables. props.modeLabel = (symbols || (inputType == InputType::Url && urlPanel)) ? tr(STR_KEY_MODE_ABC) : tr(STR_KEY_MODE_SYMBOLS); props.inputMask = static_cast(fui::InputTouch | fui::InputLongPress); props.selectedIndex = cursorMode ? -1 : static_cast(selectedLogicalIndex()); props.labelText.font = fui::GfxRendererTarget::FONT_BODY; props.altText.font = fui::GfxRendererTarget::FONT_SMALL; props.gap = static_cast(metrics.keyboardKeySpacing); props.padding = fui::Insets{0, 0, 0, 0}; // Fingers land low on the bottom row (occlusion) and there is no key below // to catch the miss — extend its hit band down to the button hints bar. const int hintsTop = renderer.getScreenHeight() - metrics.buttonHintsHeight; props.bottomHitOverflow = static_cast(std::max(0, hintsTop - (kbRect.y + kbRect.height))); fui::keyboard(frame, kbRect, props); interactionsReady = true; const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawSideButtonHints(renderer, ">", "<"); renderer.displayBuffer(); } void KeyboardEntryActivity::onComplete(std::string text) { setResult(KeyboardResult{std::move(text)}); finish(); } void KeyboardEntryActivity::onCancel() { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); }