963 lines
37 KiB
C++
963 lines
37 KiB
C++
#include "KeyboardEntryActivity.h"
|
|
|
|
#include <HalGPIO.h>
|
|
#include <I18n.h>
|
|
|
|
#include <algorithm>
|
|
#include <cstring>
|
|
|
|
#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<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 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<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;
|
|
}
|
|
}
|
|
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<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;
|
|
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>(lineStartIdx);
|
|
return true;
|
|
}
|
|
if (x >= lineStartX + textWidth) {
|
|
position = static_cast<size_t>(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<size_t>(i);
|
|
return true;
|
|
}
|
|
previousWidth = nextWidth;
|
|
}
|
|
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) {
|
|
position = x < lastLineStartX + lastLineWidth ? static_cast<size_t>(lastLineStartIdx)
|
|
: static_cast<size_t>(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<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.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<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 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<int>(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<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 (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<uint16_t>(fui::InputTouch | fui::InputLongPress);
|
|
props.selectedIndex = cursorMode ? -1 : static_cast<int16_t>(selectedLogicalIndex());
|
|
props.labelText.font = 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);
|
|
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();
|
|
}
|