Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
41 kB · 1011 lines
C++
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012#include "KeyboardEntryActivity.h"
#include <BidiUtils.h>#include <HalGPIO.h>#include <I18n.h>
#include <algorithm>#include <cstring>
#include "KeyboardLayoutSet.h"#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};
} // namespace
void KeyboardEntryActivity::onEnter() { Activity::onEnter(); cursorPos = text.length(); // URL layers are EN-arranged app tables; everything else opens on the UI // language's layout, or on an enabled one if the user switched that off. layoutId = inputType == InputType::Url ? fui::KeyboardLayoutId::QwertyEn : keyboard_layouts::startingLayout(); // The key only earns its slot in the bottom row with somewhere to go. const uint16_t enabledLayouts = keyboard_layouts::enabled(); showLangKey = (enabledLayouts & (enabledLayouts - 1)) != 0; 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, showLangKey);}
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<uint8_t>(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<uint8_t>(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 fui::QWERTY_KEY_LANG: { delPressCount = 0; hintVisible = false; const fui::KeyboardLayoutId nextId = keyboard_layouts::next(layoutId); // The non-Latin tables draw the key even with one layout enabled; a // full-screen e-ink repaint for an unchanged keyboard costs a second. if (nextId == layoutId) return false; layoutId = nextId; // Shift is per-layer: carrying it across would strand the new layout in // upper case. Row widths differ between scripts (Cyrillic runs 12/11/11 // against Latin's 10/9/9), so the selection has to be re-clamped. 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;}
bool KeyboardEntryActivity::rangeIsRtl(std::string& s, const int start, const int end) const { if (end <= start) return false; const char saved = s[end]; s[end] = '\0'; const bool isRtl = BidiUtils::detectParagraphLevel(s.c_str() + start, 0, end - start) != 0; s[end] = saved; return isRtl;}
int KeyboardEntryActivity::lineBreakEnd(std::string& s, const int start, const int maxWidth) const { const int len = static_cast<int>(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; } }
// The byte-index search can stop inside a character; snap back to a boundary, // keeping one whole character so the wrap loop always advances. const int firstCharEnd = static_cast<int>(utf8Next(s, static_cast<size_t>(start))); while (best > start && (static_cast<uint8_t>(s[best]) & 0xC0) == 0x80) best--; // Widths measured mid-character are unreliable, so the search can overshoot. while (best > firstCharEnd && measureRange(s, start, best) > maxWidth) { best = static_cast<int>(utf8Prev(s, static_cast<size_t>(best))); } return best < firstCharEnd ? firstCharEnd : 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<int>(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; const bool isRtl = rangeIsRtl(displayText, lineStartIdx, lineEndIdx); lastLineStartIdx = lineStartIdx; lastLineEndIdx = lineEndIdx; lastLineStartX = lineStartX; lastLineWidth = textWidth;
if (y >= lineY - metrics.verticalSpacing && y < lineY + lineHeight + metrics.verticalSpacing) { if (x <= lineStartX) { position = static_cast<size_t>(isRtl ? lineEndIdx : lineStartIdx); return true; } if (x >= lineStartX + textWidth) { position = static_cast<size_t>(isRtl ? lineStartIdx : lineEndIdx); return true; }
int previousWidth = 0; for (int i = lineStartIdx; i < lineEndIdx;) { const int next = static_cast<int>(utf8Next(displayText, static_cast<size_t>(i))); const int nextWidth = measureRange(displayText, lineStartIdx, next); const int halfAdvance = (nextWidth - previousWidth) / 2; const int midpoint = isRtl ? lineStartX + textWidth - previousWidth - halfAdvance : lineStartX + previousWidth + halfAdvance; if ((isRtl && x >= midpoint) || (!isRtl && x < midpoint)) { position = static_cast<size_t>(i); return true; } previousWidth = nextWidth; i = next; } position = static_cast<size_t>(lineEndIdx); return true; }
if (lineEndIdx == static_cast<int>(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) { const bool isRtl = rangeIsRtl(displayText, lastLineStartIdx, lastLineEndIdx); const bool insideText = x < lastLineStartX + lastLineWidth; position = static_cast<size_t>(insideText == isRtl ? lastLineEndIdx : lastLineStartIdx); 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<int16_t>(x), static_cast<int16_t>(y), static_cast<int16_t>(width), static_cast<int16_t>(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<uint8_t>(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<int16_t>(tx), static_cast<int16_t>(ty), tapped, static_cast<int16_t>(tapX), static_cast<int16_t>(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.wasReleased(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;
// The cursor spans a whole code point: a lone byte of it renders as a replacement glyph. // Masking is per byte, so displayText keeps text's length and the same span applies to both. const size_t cursorCharBytes = (cursorPos < text.length()) ? utf8Next(text, cursorPos) - cursorPos : 0; char cursorChar[8] = {}; // the character under the cursor char displayCursorChar[8] = {}; // same span of displayText, masked for passwords if (cursorCharBytes > 0) { const size_t n = std::min(cursorCharBytes, sizeof(cursorChar) - 1); memcpy(cursorChar, text.data() + cursorPos, n); memcpy(displayCursorChar, displayText.data() + cursorPos, n); }
int cursorCharWidth = 6; if (cursorCharBytes > 0) { int w = renderer.getTextWidth(UI_12_FONT_ID, cursorChar); 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 isRtl = rangeIsRtl(displayText, lineStartIdx, lineEndIdx); const int lineStartX = centerText ? effectiveMargin + (maxLineWidth - textWidth) / 2 : effectiveMargin; const bool isLastLine = (lineEndIdx == static_cast<int>(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 throughCursorWidth = beforeWidth; int kernOffset = 0; if (cursorCharBytes > 0) { std::string beforeAndCursor = beforeCursor + displayCursorChar; throughCursorWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, beforeAndCursor.c_str(), EpdFontFamily::REGULAR); int charAdvance = renderer.getTextAdvanceX(UI_12_FONT_ID, displayCursorChar, EpdFontFamily::REGULAR); kernOffset = throughCursorWidth - beforeWidth - charAdvance; } if (isRtl) { const int logicalWidth = cursorMode && cursorCharBytes > 0 ? throughCursorWidth : beforeWidth; cursorPixelX = lineStartX + textWidth - logicalWidth; } else { cursorPixelX = lineStartX + beforeWidth + kernOffset; } cursorLineY = inputStartY + inputHeight; cursorDrawn = true; isCursorLine = true; }
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<int>(cursorPos + cursorCharBytes); 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<int>(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 (cursorCharBytes > 0) { renderer.drawText(UI_12_FONT_ID, cursorPixelX, cursorLineY, cursorChar, 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() (the main task) routes touch snapshots against // that table via TouchHoldRouter, which reads the published generation // (routePublished()/publishedData()) — beginPublishCycle() here makes this // render build into the OTHER generation, so loop() never sees a // half-rebuilt table no matter when it runs relative to this. interactions.beginPublishCycle(); 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<56> 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<uint16_t>(fui::InputTouch | fui::InputLongPress); props.selectedIndex = cursorMode ? -1 : static_cast<int16_t>(selectedLogicalIndex()); // The 12-column Arabic rows need the smaller font for wide isolated letters. props.labelText.font = layoutId == fui::KeyboardLayoutId::ArabicAr && !symbols ? fui::GfxRendererTarget::FONT_SMALL : fui::GfxRendererTarget::FONT_BODY; props.altText.font = fui::GfxRendererTarget::FONT_SMALL; props.gap = static_cast<int16_t>(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<int16_t>(std::max(0, hintsTop - (kbRect.y + kbRect.height))); fui::keyboard(frame, kbRect, props); interactions.publish(); 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();}