diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 235a55c1f..e34403e37 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -190,20 +190,18 @@ void WifiSelectionActivity::selectNetwork(const int index) { // Show password entry state = WifiSelectionState::PASSWORD_ENTRY; // Don't allow screen updates while changing activity - startActivityForResult( - std::make_unique(renderer, mappedInput, tr(STR_ENTER_WIFI_PASSWORD), - "", // No initial text - 64, // Max password length - false // Show password by default (hard keyboard to use) - ), - [this](const ActivityResult& result) { - if (result.isCancelled) { - state = WifiSelectionState::NETWORK_LIST; - } else { - enteredPassword = std::get(result.data).text; - // state will be updated in next loop iteration - } - }); + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_ENTER_WIFI_PASSWORD), + "", // No initial text + 64, // Max password length + InputType::Password), + [this](const ActivityResult& result) { + if (result.isCancelled) { + state = WifiSelectionState::NETWORK_LIST; + } else { + enteredPassword = std::get(result.data).text; + // state will be updated in next loop iteration + } + }); } else { // Connect directly for open networks attemptConnection(); diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 8fcf9aba3..a353dd4a3 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -50,13 +50,17 @@ void CalibreSettingsActivity::loop() { void CalibreSettingsActivity::handleSelection() { if (selectedIndex == 0) { - // OPDS Server URL + // OPDS Server URL - prefill with https:// if empty to save typing + const std::string currentUrl = SETTINGS.opdsServerUrl; + const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), - SETTINGS.opdsServerUrl, 127, false), + prefillUrl, 127, InputType::Url), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& kb = std::get(result.data); - strncpy(SETTINGS.opdsServerUrl, kb.text.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); + const std::string urlToSave = + (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; + strncpy(SETTINGS.opdsServerUrl, urlToSave.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; SETTINGS.saveToFile(); } @@ -64,7 +68,7 @@ void CalibreSettingsActivity::handleSelection() { } else if (selectedIndex == 1) { // Username startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_USERNAME), - SETTINGS.opdsUsername, 63, false), + SETTINGS.opdsUsername, 63, InputType::Text), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& kb = std::get(result.data); @@ -76,7 +80,7 @@ void CalibreSettingsActivity::handleSelection() { } else if (selectedIndex == 2) { // Password startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_PASSWORD), - SETTINGS.opdsPassword, 63, false), + SETTINGS.opdsPassword, 63, InputType::Password), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& kb = std::get(result.data); diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 7291c8c1c..60edddcc0 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -54,9 +54,7 @@ void KOReaderSettingsActivity::handleSelection() { if (selectedIndex == 0) { // Username startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_KOREADER_USERNAME), - KOREADER_STORE.getUsername(), - 64, // maxLength - false), // not password + KOREADER_STORE.getUsername(), 64, InputType::Text), [this](const ActivityResult& result) { if (!result.isCancelled) { const auto& kb = std::get(result.data); @@ -66,33 +64,31 @@ void KOReaderSettingsActivity::handleSelection() { }); } else if (selectedIndex == 1) { // Password - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_KOREADER_PASSWORD), - KOREADER_STORE.getPassword(), - 64, // maxLength - false), // show characters - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), kb.text); - KOREADER_STORE.saveToFile(); - } - }); + startActivityForResult( + std::make_unique(renderer, mappedInput, tr(STR_KOREADER_PASSWORD), + KOREADER_STORE.getPassword(), 64, InputType::Password), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), kb.text); + KOREADER_STORE.saveToFile(); + } + }); } else if (selectedIndex == 2) { // Sync Server URL - prefill with https:// if empty to save typing const std::string currentUrl = KOREADER_STORE.getServerUrl(); const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; - startActivityForResult( - std::make_unique(renderer, mappedInput, tr(STR_SYNC_SERVER_URL), prefillUrl, - 128, // maxLength - URLs can be long - false), // not password - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - const std::string urlToSave = (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; - KOREADER_STORE.setServerUrl(urlToSave); - KOREADER_STORE.saveToFile(); - } - }); + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_SYNC_SERVER_URL), + prefillUrl, 128, InputType::Url), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + const std::string urlToSave = + (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; + KOREADER_STORE.setServerUrl(urlToSave); + KOREADER_STORE.saveToFile(); + } + }); } else if (selectedIndex == 3) { // Document Matching - toggle between Filename and Binary const auto current = KOREADER_STORE.getMatchMethod(); diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index b763a2b61..7f25f6857 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -1,192 +1,362 @@ #include "KeyboardEntryActivity.h" +#include #include +#include + #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" -// Keyboard layouts - lowercase -const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = { - "`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./", - "^ _____?", "SPECIAL ROW"}; - -// Shift state strings -const char* const KeyboardEntryActivity::shiftString[3] = {"shift", "SHIFT", "LOCK"}; +const char* const KeyboardEntryActivity::shiftString[2] = {"shift", "SHIFT"}; void KeyboardEntryActivity::onEnter() { Activity::onEnter(); - - // Trigger first update + cursorPos = text.length(); + symMode = false; + urlMode = false; + cursorMode = false; + togglePos = false; + passwordVisible = false; + shiftState = 0; + selectedRow = 0; + selectedCol = 0; + delPressCount = 0; + hintVisible = false; + hintShowTime = 0; + rightHeld = false; + rightLongHandled = false; + savedCursorPos = 0; + rightStartCursorPos = 0; requestUpdate(); } void KeyboardEntryActivity::onExit() { Activity::onExit(); } -int KeyboardEntryActivity::getRowLength(const int row) const { - if (row < 0 || row >= NUM_ROWS) return 0; - - // Return actual length of each row based on keyboard layout - switch (row) { - case 0: - return 13; // `1234567890-= - case 1: - return 13; // qwertyuiop[]backslash - case 2: - return 11; // asdfghjkl;' - case 3: - return 10; // zxcvbnm,./ - case 4: - return 11; // shift (2 wide), space (5 wide), backspace (2 wide), OK (2 wide) - default: - return 0; - } +int KeyboardEntryActivity::getContentRowCount() const { + if (urlMode) return 3; + return ABC_ROWS; } +int KeyboardEntryActivity::getContentColCount() const { + if (urlMode) return 3; + return COLS; +} + +int KeyboardEntryActivity::getTotalRowCount() const { return getContentRowCount() + 1; } + +bool KeyboardEntryActivity::isBottomRow(const int row) const { return row == getContentRowCount(); } + char KeyboardEntryActivity::getSelectedChar() const { - const char* const* layout = shiftState ? keyboardShift : keyboard; + const KeyDef(*layout)[COLS] = symMode ? symLayout : abcLayout; - if (selectedRow < 0 || selectedRow >= NUM_ROWS) return '\0'; - if (selectedCol < 0 || selectedCol >= getRowLength(selectedRow)) return '\0'; + if (selectedRow < 0 || selectedRow >= getContentRowCount()) return '\0'; + if (selectedCol < 0 || selectedCol >= COLS) return '\0'; - return layout[selectedRow][selectedCol]; + const KeyDef& key = layout[selectedRow][selectedCol]; + return (shiftState > 0 && key.secondary != '\0') ? key.secondary : key.primary; } -bool KeyboardEntryActivity::handleKeyPress() { - // Handle special row (bottom row with shift, space, backspace, done) - if (selectedRow == SPECIAL_ROW) { - if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { - // Shift toggle (0 = lower case, 1 = upper case, 2 = shift lock) - shiftState = (shiftState + 1) % 3; - return true; - } +char KeyboardEntryActivity::getAlternativeChar() const { + if (symMode || urlMode) return '\0'; + if (inputType == InputType::Url && selectedRow > 0) return '\0'; - if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { - // Space bar - if (maxLength == 0 || text.length() < maxLength) { - text += ' '; - } - return true; - } + const KeyDef(*layout)[COLS] = abcLayout; - if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { - // Backspace - if (!text.empty()) { - text.pop_back(); - } - return true; - } + if (selectedRow < 0 || selectedRow >= getContentRowCount()) return '\0'; + if (selectedCol < 0 || selectedCol >= COLS) return '\0'; - if (selectedCol >= DONE_COL) { - // Done button - onComplete(text); - return false; - } - } + const KeyDef& key = layout[selectedRow][selectedCol]; + const char current = getSelectedChar(); + if (current == key.primary && key.secondary != '\0') return key.secondary; + if (current == key.secondary) return key.primary; + return '\0'; +} - // Regular character - const char c = getSelectedChar(); - if (c == '\0') { - return true; - } - - if (maxLength == 0 || text.length() < maxLength) { - text += c; - // Auto-disable shift after typing a character in non-lock mode - if (shiftState == 1) { - shiftState = 0; - } - } +bool KeyboardEntryActivity::insertChar(char c) { + if (c == '\0') return true; + if (maxLength != 0 && text.length() >= maxLength) return true; + if (cursorPos > text.length()) cursorPos = text.length(); + text.insert(cursorPos, 1, c); + cursorPos++; return true; } -void KeyboardEntryActivity::loop() { - // Handle navigation - buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [this] { - selectedRow = ButtonNavigator::previousIndex(selectedRow, NUM_ROWS); +void KeyboardEntryActivity::insertString(const std::string& str) { + if (str.empty()) return; + if (maxLength != 0 && text.length() + str.length() > maxLength) return; + if (cursorPos > text.length()) cursorPos = text.length(); - const int maxCol = getRowLength(selectedRow) - 1; - if (selectedCol > maxCol) selectedCol = maxCol; - requestUpdate(); - }); + text.insert(cursorPos, str); + cursorPos += str.length(); +} - buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [this] { - selectedRow = ButtonNavigator::nextIndex(selectedRow, NUM_ROWS); - - const int maxCol = getRowLength(selectedRow) - 1; - if (selectedCol > maxCol) selectedCol = maxCol; - requestUpdate(); - }); - - buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { - const int maxCol = getRowLength(selectedRow) - 1; - - // Special bottom row case - if (selectedRow == SPECIAL_ROW) { - // Bottom row has special key widths - if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { - // In shift key, wrap to end of row - selectedCol = maxCol; - } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { - // In space bar, move to shift - selectedCol = SHIFT_COL; - } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { - // In backspace, move to space - selectedCol = SPACE_COL; - } else if (selectedCol >= DONE_COL) { - // At done button, move to backspace - selectedCol = BACKSPACE_COL; +bool KeyboardEntryActivity::handleKeyPress() { + if (isBottomRow(selectedRow)) { + switch (static_cast(selectedCol)) { + case SpecialKeyType::Shift: + delPressCount = 0; + hintVisible = false; + if (urlMode || inputType == InputType::Url) return true; + if (symMode) return true; + shiftState = (shiftState + 1) % 2; + return true; + case SpecialKeyType::Mode: { + delPressCount = 0; + hintVisible = false; + if (urlMode) { + urlMode = false; + symMode = false; + selectedRow = getTotalRowCount() - 1; + selectedCol = static_cast(SpecialKeyType::Mode); + requestUpdate(); + return true; + } + symMode = !symMode; + int maxRow = getTotalRowCount() - 1; + if (selectedRow > maxRow) selectedRow = maxRow; + if (isBottomRow(selectedRow)) { + if (selectedCol >= BOTTOM_KEY_COUNT) selectedCol = BOTTOM_KEY_COUNT - 1; + } else { + if (selectedCol >= getContentColCount()) selectedCol = getContentColCount() - 1; + } + return true; } - } else { - selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1); + case SpecialKeyType::Space: + delPressCount = 0; + hintVisible = false; + if (inputType == InputType::Url) { + urlMode = !urlMode; + if (urlMode) { + symMode = false; + } + selectedRow = getTotalRowCount() - 1; + selectedCol = static_cast(SpecialKeyType::Space); + requestUpdate(); + } else { + return insertChar(' '); + } + return true; + case SpecialKeyType::Del: + delPressCount++; + if (delPressCount >= 2) { + hintVisible = true; + hintShowTime = millis(); + } + if (cursorPos > 0 && !text.empty()) { + text.erase(cursorPos - 1, 1); + cursorPos--; + } + return true; + case SpecialKeyType::Ok: + delPressCount = 0; + hintVisible = false; + onComplete(text); + return false; + default: + return true; } - - requestUpdate(); - }); - - buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { - const int maxCol = getRowLength(selectedRow) - 1; - - // Special bottom row case - if (selectedRow == SPECIAL_ROW) { - // Bottom row has special key widths - if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { - // In shift key, move to space - selectedCol = SPACE_COL; - } else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) { - // In space bar, move to backspace - selectedCol = BACKSPACE_COL; - } else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { - // In backspace, move to done - selectedCol = DONE_COL; - } else if (selectedCol >= DONE_COL) { - // At done button, wrap to beginning of row - selectedCol = SHIFT_COL; - } - } else { - selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 1); - } - requestUpdate(); - }); - - // Selection - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - if (handleKeyPress()) { - requestUpdate(); - } - // If handleKeyPress returns false, it means onComplete was triggered, no update needed } - // Cancel + if (urlMode) { + delPressCount = 0; + hintVisible = false; + const int idx = selectedCol + selectedRow * 3; + if (idx < URL_SNIPPET_COUNT) { + insertString(urlSnippets[idx]); + } + return true; + } + + delPressCount = 0; + hintVisible = false; + + return insertChar(getSelectedChar()); +} + +void KeyboardEntryActivity::mapColContentBottom(int& col, bool goingUp) const { + if (urlMode) { + col = goingUp ? col - 1 : col + 1; + if (col < 0) col = 0; + if (col >= 3) col = 2; + } else { + col = goingUp ? col * 2 : col / 2; + } +} + +void KeyboardEntryActivity::loop() { + const int totalRows = getTotalRowCount(); + + 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) { + bool wasBottom = isBottomRow(selectedRow); + const int contentCols = getContentColCount(); + selectedRow = ButtonNavigator::previousIndex(selectedRow, totalRows); + if (wasBottom && !isBottomRow(selectedRow)) { + mapColContentBottom(selectedCol, true); + } else if (!wasBottom && isBottomRow(selectedRow)) { + mapColContentBottom(selectedCol, false); + } + int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : contentCols - 1; + if (selectedCol > maxCol) selectedCol = maxCol; + 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) { + bool wasBottom = isBottomRow(selectedRow); + const int contentCols = getContentColCount(); + selectedRow = ButtonNavigator::nextIndex(selectedRow, totalRows); + if (wasBottom && !isBottomRow(selectedRow)) { + mapColContentBottom(selectedCol, true); + } else if (!wasBottom && isBottomRow(selectedRow)) { + mapColContentBottom(selectedCol, false); + } + int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : contentCols - 1; + if (selectedCol > maxCol) selectedCol = maxCol; + requestUpdate(); + } + downHeld = false; + downLongHandled = false; + } + + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { + if (cursorMode) return; + int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : getContentColCount() - 1; + selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1); + requestUpdate(); + }); + + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { + if (cursorMode) { + if (togglePos) { + cursorPos = savedCursorPos; + togglePos = false; + requestUpdate(); + } else if (cursorPos > 0) { + 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; + int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : getContentColCount() - 1; + selectedCol = ButtonNavigator::nextIndex(selectedCol, maxCol + 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++; + requestUpdate(); + } + if (cursorMode) return; + rightHeld = false; + rightLongHandled = false; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + confirmHeld = true; + confirmLongHandled = false; + } + + if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() > DEL_LONG_PRESS_MS && isBottomRow(selectedRow) && + selectedCol == static_cast(SpecialKeyType::Del)) { + text.clear(); + cursorPos = 0; + confirmLongHandled = true; + requestUpdate(); + } + + if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && + mappedInput.getHeldTime() > LONG_PRESS_MS) { + char alt = getAlternativeChar(); + if (alt != '\0') { + insertChar(alt); + requestUpdate(); + confirmLongHandled = true; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (confirmHeld && !confirmLongHandled && !cursorMode) { + if (handleKeyPress()) { + 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&&) { @@ -198,34 +368,106 @@ void KeyboardEntryActivity::render(RenderLock&&) { GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str()); - // Draw input field const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); - const int inputStartY = - metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.verticalSpacing * 4; + const int inputStartY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; int inputHeight = 0; std::string displayText; - if (isPassword) { - displayText = std::string(text.length(), '*'); + if (inputType == InputType::Password && !passwordVisible) { + 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; + } + displayText = text; + for (size_t i = 0; i < displayText.length(); i++) { + if (i != revealPos) { + displayText[i] = '*'; + } + } } else { displayText = text; } - // Show cursor at end - displayText += "_"; + 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; + } - // Render input text across multiple lines int lineStartIdx = 0; int lineEndIdx = displayText.length(); int textWidth = 0; + int cursorPixelX = effectiveMargin; + int cursorLineY = inputStartY; + bool cursorDrawn = false; + while (true) { std::string lineText = displayText.substr(lineStartIdx, lineEndIdx - lineStartIdx); textWidth = renderer.getTextWidth(UI_12_FONT_ID, lineText.c_str()); - if (textWidth <= pageWidth - 2 * metrics.contentSidePadding) { - if (metrics.keyboardCenteredText) { - renderer.drawCenteredText(UI_12_FONT_ID, inputStartY + inputHeight, lineText.c_str()); + if (textWidth <= maxLineWidth) { + 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.getTextWidth(UI_12_FONT_ID, beforeCursor.c_str()); + int kernOffset = 0; + if (cursorPos < displayText.length()) { + std::string beforeAndCursor = beforeCursor + displayText.substr(cursorPos, 1); + int beforeAndCursorWidth = renderer.getTextWidth(UI_12_FONT_ID, beforeAndCursor.c_str()); + int charAdvance = renderer.getTextWidth(UI_12_FONT_ID, displayText.substr(cursorPos, 1).c_str()); + 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, metrics.contentSidePadding, inputStartY + inputHeight, lineText.c_str()); + renderer.drawText(UI_12_FONT_ID, lineStartX, inputStartY + inputHeight, lineText.c_str()); } if (lineEndIdx == displayText.length()) { break; @@ -239,83 +481,256 @@ void KeyboardEntryActivity::render(RenderLock&&) { } } - GUI.drawTextField(renderer, Rect{0, inputStartY, pageWidth, inputHeight}, textWidth); + 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); - // Draw keyboard - use compact spacing to fit 5 rows on screen + 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; + renderer.drawCenteredText(SMALL_FONT_ID, hintLineY, "Press < or > to move cursor", true); + hintLineY += hintLh; + if (inputType == InputType::Password) { + const char* passTip; + if (togglePos) { + passTip = "Press < to return to cursor position"; + } else { + passTip = + passwordVisible ? "Hold > then press [***] to hide password" : "Hold > then press [abc] to show password"; + } + renderer.drawCenteredText(SMALL_FONT_ID, hintLineY, passTip, true); + } + } else { + renderer.drawCenteredText(SMALL_FONT_ID, hintY, "Hold UP to edit entry", true); + } + } + + const int keyHeight = metrics.keyboardKeyHeight; + const int bottomKeyHeight = metrics.keyboardBottomKeyHeight; + const int keySpacing = metrics.keyboardKeySpacing; + const int contentCols = getContentColCount(); + const int keyboardWidth = pageWidth * metrics.keyboardWidthPercent / 100; + const int keyWidth = (keyboardWidth - (contentCols - 1) * keySpacing) / contentCols; + const int leftMargin = (pageWidth - (contentCols * keyWidth + (contentCols - 1) * keySpacing)) / 2; + + const int bottomRowGap = metrics.keyboardBottomKeySpacing > 0 ? 4 : 0; const int keyboardStartY = metrics.keyboardBottomAligned ? pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - - (metrics.keyboardKeyHeight + metrics.keyboardKeySpacing) * NUM_ROWS - : inputStartY + inputHeight + metrics.verticalSpacing * 4; - const int keyWidth = metrics.keyboardKeyWidth; - const int keyHeight = metrics.keyboardKeyHeight; - const int keySpacing = metrics.keyboardKeySpacing; + (keyHeight + keySpacing) * getContentRowCount() - bottomKeyHeight - + bottomRowGap + metrics.keyboardVerticalOffset + : inputStartY + inputHeight + lineHeight + metrics.verticalSpacing; - const char* const* layout = shiftState ? keyboardShift : keyboard; + 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); }; - // Calculate left margin to center the longest row (13 keys) - const int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); - const int leftMargin = (pageWidth - maxRowWidth) / 2; + int tipCount = 0; + if (cursorMode) { + tipCount = 1; + } else if (urlMode) { + tipCount = 1 + (!text.empty() ? 1 : 0); + } else if (symMode) { + tipCount = !text.empty() ? 1 : 0; + } else { + tipCount = 1 + (inputType == InputType::Url ? 1 : 0) + (!text.empty() ? 1 : 0); + } - for (int row = 0; row < NUM_ROWS; row++) { - const int rowY = keyboardStartY + row * (keyHeight + keySpacing); - - // Left-align all rows for consistent navigation - const int startX = leftMargin; - - // Handle bottom row (row 4) specially with proper multi-column keys - if (row == SPECIAL_ROW) { - // Bottom row layout: SHIFT (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols) - // Total: 11 visual columns, but we use logical positions for selection - - int currentX = startX; - - // SHIFT key (logical col 0, spans 2 key widths) - const bool shiftSelected = (selectedRow == SPECIAL_ROW && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL); - const int shiftWidth = SPACE_COL - SHIFT_COL; - const int shiftXWidth = shiftWidth * (keyWidth + keySpacing); - GUI.drawKeyboardKey(renderer, Rect{currentX, rowY, shiftXWidth, keyHeight}, shiftString[shiftState], - shiftSelected); - currentX += shiftXWidth; - - // Space bar (logical cols 2-6, spans 5 key widths) - const bool spaceSelected = - (selectedRow == SPECIAL_ROW && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); - const int spaceWidth = BACKSPACE_COL - SPACE_COL; - const int spaceXWidth = spaceWidth * (keyWidth + keySpacing); - GUI.drawKeyboardKey(renderer, Rect{currentX, rowY, spaceXWidth, keyHeight}, "_____", spaceSelected); - currentX += spaceXWidth; - - // Backspace key (logical col 7, spans 2 key widths) - const bool bsSelected = (selectedRow == SPECIAL_ROW && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL); - const int backspaceWidth = DONE_COL - BACKSPACE_COL; - const int backspaceXWidth = backspaceWidth * (keyWidth + keySpacing); - GUI.drawKeyboardKey(renderer, Rect{currentX, rowY, backspaceXWidth, keyHeight}, "<-", bsSelected); - currentX += backspaceXWidth; - - // OK button (logical col 9, spans 2 key widths) - const bool okSelected = (selectedRow == SPECIAL_ROW && selectedCol >= DONE_COL); - const int okWidth = getRowLength(row) - DONE_COL; - const int okXWidth = okWidth * (keyWidth + keySpacing); - GUI.drawKeyboardKey(renderer, Rect{currentX, rowY, okXWidth, keyHeight}, tr(STR_OK_BUTTON), okSelected); + if (tipCount > 0) { + int y = (underlineBottom + keyboardStartY) / 2 - (tipCount + 1) * tipsLh / 2; + drawTip("Tips:", y); + y += tipsLh; + if (cursorMode) { + drawTip("Press DOWN to return to keyboard", y); + } else if (urlMode) { + drawTip("Press ABC to exit URL mode", y); + y += tipsLh; + if (!text.empty()) { + drawTip("Hold DEL to clear all text", y); + } + } else if (symMode) { + if (!text.empty()) { + drawTip("Hold DEL to clear all text", y); + } } else { - // Regular rows: render each key individually - for (int col = 0; col < getRowLength(row); col++) { - // Get the character to display - const char c = layout[row][col]; - std::string keyLabel(1, c); - - const int keyX = startX + col * (keyWidth + keySpacing); - const bool isSelected = row == selectedRow && col == selectedCol; - GUI.drawKeyboardKey(renderer, Rect{keyX, rowY, keyWidth, keyHeight}, keyLabel.c_str(), isSelected); + const char* altCharTip; + if (inputType == InputType::Url) { + altCharTip = "Hold SELECT for secondary char"; + } else if (shiftState > 0) { + altCharTip = "Hold SELECT for lowercase or secondary char"; + } else { + altCharTip = "Hold SELECT for UPPERCASE or secondary char"; + } + drawTip(altCharTip, y); + y += tipsLh; + if (inputType == InputType::Url) { + drawTip("Press URL for snippets", y); + y += tipsLh; + } + if (!text.empty()) { + drawTip("Hold DEL to clear all text", y); } } } - // Draw help text + const int bkSpacing = metrics.keyboardBottomKeySpacing; + const int abcKeyWidth = (keyboardWidth - (COLS - 1) * keySpacing) / COLS; + const int contentTotalWidth = COLS * abcKeyWidth + (COLS - 1) * keySpacing; + const int bottomKeyWidth = (contentTotalWidth - (BOTTOM_KEY_COUNT - 1) * bkSpacing) / BOTTOM_KEY_COUNT; + const int bottomLeftMargin = + (pageWidth - (BOTTOM_KEY_COUNT * bottomKeyWidth + (BOTTOM_KEY_COUNT - 1) * bkSpacing)) / 2; + + int urlLeftMargin = leftMargin; + if (urlMode) { + const int urlTotalWidth = 3 * keyWidth + 2 * keySpacing; + const int urlCenterX = + bottomLeftMargin + static_cast(SpecialKeyType::Space) * (bottomKeyWidth + bkSpacing) + bottomKeyWidth / 2; + urlLeftMargin = urlCenterX - urlTotalWidth / 2; + } + + const KeyDef(*layout)[COLS] = symMode ? symLayout : abcLayout; + const int contentRows = getContentRowCount(); + + for (int row = 0; row < contentRows; row++) { + const int rowY = keyboardStartY + row * (keyHeight + keySpacing); + const int rowLeftMargin = urlMode ? urlLeftMargin : leftMargin; + + for (int col = 0; col < contentCols; col++) { + const int keyX = rowLeftMargin + col * (keyWidth + keySpacing); + const bool isSelected = row == selectedRow && col == selectedCol; + const bool activeKeySelected = isSelected && !cursorMode; + + if (urlMode) { + const int snippetIdx = col + row * 3; + if (snippetIdx < URL_SNIPPET_COUNT) { + GUI.drawKeyboardKey(renderer, Rect{keyX, rowY, keyWidth, keyHeight}, urlSnippets[snippetIdx], + activeKeySelected, nullptr); + } + } else { + const KeyDef& key = layout[row][col]; + + char primaryChar = key.primary; + char secondaryChar = key.secondary; + + if (!symMode && shiftState > 0 && key.secondary != '\0') { + primaryChar = key.secondary; + secondaryChar = key.primary; + } + + const char primaryBuf[2] = {primaryChar, '\0'}; + const char secondaryBuf[2] = {secondaryChar, '\0'}; + const bool showSecondary = !symMode && row == 0 && secondaryChar != '\0'; + GUI.drawKeyboardKey(renderer, Rect{keyX, rowY, keyWidth, keyHeight}, primaryBuf, activeKeySelected, + showSecondary ? secondaryBuf : nullptr); + } + } + } + + const int bottomRowY = keyboardStartY + contentRows * (keyHeight + keySpacing) + bottomRowGap; + const bool bottomSelected = isBottomRow(selectedRow); + + struct BottomKeyInfo { + KeyboardKeyType themeType; + const char* label; + }; + const BottomKeyInfo bottomKeys[BOTTOM_KEY_COUNT] = { + {(symMode || urlMode || inputType == InputType::Url) ? KeyboardKeyType::Disabled : KeyboardKeyType::Shift, + (symMode || urlMode || inputType == InputType::Url) ? shiftString[0] : shiftString[shiftState]}, + {KeyboardKeyType::Mode, urlMode ? "abc" : (symMode ? "abc" : "#@!")}, + {inputType == InputType::Url ? KeyboardKeyType::Mode : KeyboardKeyType::Space, + inputType == InputType::Url ? "URL" : nullptr}, + {KeyboardKeyType::Del, nullptr}, + {KeyboardKeyType::Ok, tr(STR_OK_BUTTON)}, + }; + + for (int i = 0; i < BOTTOM_KEY_COUNT; i++) { + const int keyX = bottomLeftMargin + i * (bottomKeyWidth + bkSpacing); + const bool isSelected = bottomSelected && i == selectedCol; + + const bool activeKeySelected = isSelected && !cursorMode; + GUI.drawKeyboardKey(renderer, Rect{keyX, bottomRowY, bottomKeyWidth, bottomKeyHeight}, bottomKeys[i].label, + activeKeySelected, nullptr, bottomKeys[i].themeType); + } + + if (cursorMode) { + int selKeyX, selKeyY, selKeyW, selKeyH; + if (isBottomRow(selectedRow)) { + selKeyX = bottomLeftMargin + selectedCol * (bottomKeyWidth + bkSpacing); + selKeyY = bottomRowY; + selKeyW = bottomKeyWidth; + selKeyH = bottomKeyHeight; + } else { + const int rowLM = urlMode ? urlLeftMargin : leftMargin; + selKeyX = rowLM + selectedCol * (keyWidth + keySpacing); + selKeyY = keyboardStartY + selectedRow * (keyHeight + keySpacing); + selKeyW = keyWidth; + selKeyH = keyHeight; + } + if (isBottomRow(selectedRow)) { + GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, bottomKeys[selectedCol].label, true, + nullptr, bottomKeys[selectedCol].themeType, true); + } else if (urlMode) { + const int idx = selectedCol + selectedRow * 3; + if (idx < URL_SNIPPET_COUNT) { + GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, urlSnippets[idx], true, nullptr, + KeyboardKeyType::Normal, true); + } + } else { + const KeyDef& selKey = layout[selectedRow][selectedCol]; + char selPrimary = selKey.primary; + char selSecondary = selKey.secondary; + if (!symMode && shiftState > 0 && selKey.secondary != '\0') { + selPrimary = selKey.secondary; + selSecondary = selKey.primary; + } + const char selPrimaryBuf[2] = {selPrimary, '\0'}; + const char selSecondaryBuf[2] = {selSecondary, '\0'}; + const bool selShowSecondary = !symMode && selectedRow == 0 && selSecondary != '\0'; + GUI.drawKeyboardKey(renderer, Rect{selKeyX, selKeyY, selKeyW, selKeyH}, selPrimaryBuf, true, + selShowSecondary ? selSecondaryBuf : nullptr, KeyboardKeyType::Normal, 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); - // Draw side button hints for Up/Down navigation GUI.drawSideButtonHints(renderer, ">", "<"); renderer.displayBuffer(); diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index e7e6b8cf2..c6b659cb3 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,31 +9,26 @@ #include "../Activity.h" #include "util/ButtonNavigator.h" -/** - * Reusable keyboard entry activity for text input. - * Can be started from any activity that needs text entry via startActivityForResult() - */ +struct KeyDef { + char primary; + char secondary; +}; + +enum class SpecialKeyType { Shift, Mode, Space, Del, Ok }; + +enum class InputType { Text, Password, Url }; + class KeyboardEntryActivity : public Activity { public: - /** - * Constructor - * @param renderer Reference to the GfxRenderer for drawing - * @param mappedInput Reference to MappedInputManager for handling input - * @param title Title to display above the keyboard - * @param initialText Initial text to show in the input field - * @param maxLength Maximum length of input text (0 for unlimited) - * @param isPassword If true, display asterisks instead of actual characters - */ explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title = "Enter Text", std::string initialText = "", - const size_t maxLength = 0, const bool isPassword = false) + const size_t maxLength = 0, InputType inputType = InputType::Text) : Activity("KeyboardEntry", renderer, mappedInput), title(std::move(title)), text(std::move(initialText)), maxLength(maxLength), - isPassword(isPassword) {} + inputType(inputType) {} - // Activity overrides void onEnter() override; void onExit() override; void loop() override; @@ -42,34 +38,146 @@ class KeyboardEntryActivity : public Activity { std::string title; std::string text; size_t maxLength; - bool isPassword; + InputType inputType; + bool passwordVisible = false; ButtonNavigator buttonNavigator; - // Keyboard state int selectedRow = 0; int selectedCol = 0; - int shiftState = 0; // 0 = lower case, 1 = upper case, 2 = shift lock) + int shiftState = 0; + bool symMode = false; + bool confirmHeld = false; + bool confirmLongHandled = false; + + bool cursorMode = false; + bool togglePos = false; + size_t cursorPos = 0; + bool upHeld = false; + bool upLongHandled = false; + bool downHeld = false; + bool downLongHandled = false; + bool rightHeld = false; + bool rightLongHandled = false; + size_t savedCursorPos = 0; + size_t rightStartCursorPos = 0; + + bool urlMode = false; + static constexpr int URL_SNIPPET_COUNT = 9; + static constexpr const char* const urlSnippets[URL_SNIPPET_COUNT] = { + "https://", "www.", ".com", "http://", "192.168.", ".org", "/opds", ":8080", ".net"}; + + int delPressCount = 0; + bool hintVisible = false; + unsigned long hintShowTime = 0; - // Handlers void onComplete(std::string text); void onCancel(); - // Keyboard layout - static constexpr int NUM_ROWS = 5; - static constexpr int KEYS_PER_ROW = 13; // Max keys per row (rows 0 and 1 have 13 keys) - static const char* const keyboard[NUM_ROWS]; - static const char* const keyboardShift[NUM_ROWS]; - static const char* const shiftString[3]; + static constexpr uint16_t LONG_PRESS_MS = 500; + static constexpr uint16_t DEL_LONG_PRESS_MS = 1500; - // Special key positions (bottom row) - static constexpr int SPECIAL_ROW = 4; - static constexpr int SHIFT_COL = 0; - static constexpr int SPACE_COL = 2; - static constexpr int BACKSPACE_COL = 7; - static constexpr int DONE_COL = 9; + static constexpr int COLS = 10; + static constexpr int ABC_ROWS = 4; + static constexpr int SYM_ROWS = 4; + static constexpr int BOTTOM_KEY_COUNT = 5; + static constexpr KeyDef abcLayout[ABC_ROWS][COLS] = { + {{'1', '!'}, + {'2', '@'}, + {'3', '#'}, + {'4', '$'}, + {'5', '%'}, + {'6', '^'}, + {'7', '&'}, + {'8', '*'}, + {'9', '('}, + {'0', ')'}}, + {{'q', 'Q'}, + {'w', 'W'}, + {'e', 'E'}, + {'r', 'R'}, + {'t', 'T'}, + {'y', 'Y'}, + {'u', 'U'}, + {'i', 'I'}, + {'o', 'O'}, + {'p', 'P'}}, + {{'a', 'A'}, + {'s', 'S'}, + {'d', 'D'}, + {'f', 'F'}, + {'g', 'G'}, + {'h', 'H'}, + {'j', 'J'}, + {'k', 'K'}, + {'l', 'L'}, + {'-', '_'}}, + {{'z', 'Z'}, + {'x', 'X'}, + {'c', 'C'}, + {'v', 'V'}, + {'b', 'B'}, + {'n', 'N'}, + {'m', 'M'}, + {'=', '+'}, + {'.', '>'}, + {',', '<'}}, + }; + + static constexpr KeyDef symLayout[SYM_ROWS][COLS] = { + {{'1', '\0'}, + {'2', '\0'}, + {'3', '\0'}, + {'4', '\0'}, + {'5', '\0'}, + {'6', '\0'}, + {'7', '\0'}, + {'8', '\0'}, + {'9', '\0'}, + {'0', '\0'}}, + {{'!', '\0'}, + {'@', '\0'}, + {'#', '\0'}, + {'$', '\0'}, + {'%', '\0'}, + {'^', '\0'}, + {'&', '\0'}, + {'*', '\0'}, + {'(', '\0'}, + {')', '\0'}}, + {{'-', '\0'}, + {'_', '\0'}, + {'=', '\0'}, + {'+', '\0'}, + {'[', '\0'}, + {']', '\0'}, + {'{', '\0'}, + {'}', '\0'}, + {';', '\0'}, + {':', '\0'}}, + {{'\'', '\0'}, + {'"', '\0'}, + {'/', '\0'}, + {'\\', '\0'}, + {'|', '\0'}, + {'?', '\0'}, + {'.', '\0'}, + {',', '\0'}, + {'~', '\0'}, + {'`', '\0'}}, + }; + + static const char* const shiftString[2]; + + int getContentRowCount() const; + int getContentColCount() const; + int getTotalRowCount() const; + bool isBottomRow(int row) const; char getSelectedChar() const; - bool handleKeyPress(); // false if onComplete was triggered - int getRowLength(int row) const; + char getAlternativeChar() const; + bool handleKeyPress(); + bool insertChar(char c); + void insertString(const std::string& str); + void mapColContentBottom(int& col, bool goingUp) const; }; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index dff5ec065..d02268a45 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -781,18 +781,70 @@ void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* renderer.drawCenteredText(SMALL_FONT_ID, rect.y, truncatedLabel.c_str()); } -void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const { - renderer.drawText(UI_12_FONT_ID, rect.x + 10, rect.y, "["); - renderer.drawText(UI_12_FONT_ID, rect.x + rect.width - 15, rect.y + rect.height, "]"); +void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode, + int contentStartX, int contentWidth) const { + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int lineY = rect.y + rect.height + lineHeight + BaseMetrics::values.verticalSpacing; + const int thickness = cursorMode ? 3 : 1; + if (contentWidth > 0) { + renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth, lineY, thickness, true); + } else { + const int hPadding = 4; + const int lineW = textWidth + hPadding * 2; + renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, lineY, thickness, + true); + } } -void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, - const bool isSelected) const { - const int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label); - const int textX = rect.x + (rect.width - itemWidth) / 2; +void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, + const char* secondaryLabel, const KeyboardKeyType keyType, + const bool inactiveSelection) const { if (isSelected) { - renderer.drawText(UI_10_FONT_ID, textX - 6, rect.y, "["); - renderer.drawText(UI_10_FONT_ID, textX + itemWidth, rect.y, "]"); + if (inactiveSelection) { + renderer.drawRect(rect.x, rect.y, rect.width, rect.height, 2, true); + } else if (keyType == KeyboardKeyType::Disabled) { + renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); + } else { + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, true); + } + } else if (keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Del || + keyType == KeyboardKeyType::Space || keyType == KeyboardKeyType::Ok || + keyType == KeyboardKeyType::Disabled) { + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); } - renderer.drawText(UI_10_FONT_ID, textX, rect.y, label); + + const bool invert = isSelected && !inactiveSelection; + + if (keyType == KeyboardKeyType::Space) { + const int lineHalfWidth = rect.width * 3 / 10; + const int centerX = rect.x + rect.width / 2; + const int lineY = rect.y + rect.height / 2 + 3; + renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); + return; + } + + if (keyType == KeyboardKeyType::Del) { + const int centerX = rect.x + rect.width / 2; + const int centerY = rect.y + rect.height / 2; + const int arrowLen = rect.width / 4; + const int arrowHead = arrowLen / 2; + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, + !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, + !invert); + return; + } + + const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; + const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label); + const int textX = rect.x + (rect.width - itemWidth) / 2; + const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; + + if (hasSecondary) { + const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); + renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - 1, rect.y, secondaryLabel, !invert); + } + + renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 5476c085e..512b9f867 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -60,12 +60,19 @@ struct ThemeMetrics { int keyboardKeyWidth; int keyboardKeyHeight; int keyboardKeySpacing; + int keyboardBottomKeyHeight; + int keyboardBottomKeySpacing; bool keyboardBottomAligned; bool keyboardCenteredText; + int keyboardVerticalOffset; + int keyboardTextFieldWidthPercent; + int keyboardWidthPercent; }; enum UIIcon { Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot }; +enum class KeyboardKeyType { Normal, Shift, Mode, Space, Del, Ok, Disabled }; + // Default theme implementation (Classic Theme) // Additional themes can inherit from this and override methods as needed @@ -96,10 +103,15 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, .keyboardKeyWidth = 22, - .keyboardKeyHeight = 30, - .keyboardKeySpacing = 10, - .keyboardBottomAligned = false, - .keyboardCenteredText = false}; + .keyboardKeyHeight = 40, + .keyboardKeySpacing = 0, + .keyboardBottomKeyHeight = 35, + .keyboardBottomKeySpacing = 5, + .keyboardBottomAligned = true, + .keyboardCenteredText = false, + .keyboardVerticalOffset = -13, + .keyboardTextFieldWidthPercent = 85, + .keyboardWidthPercent = 90}; } class BaseTheme { @@ -139,8 +151,11 @@ class BaseTheme { const int pageCount, std::string title, const int paddingBottom = 0, const int textYOffset = 0) const; virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; - virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const; - virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const; + virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, + int contentStartX = 0, int contentWidth = 0) const; + virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, + const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, + bool inactiveSelection = false) const; virtual bool showsFileIcons() const { return false; } // Shared constants and helpers for battery drawing (used by all themes) diff --git a/src/components/themes/lyra/Lyra3CoversTheme.h b/src/components/themes/lyra/Lyra3CoversTheme.h index dde920011..5d2bd9740 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.h +++ b/src/components/themes/lyra/Lyra3CoversTheme.h @@ -34,10 +34,15 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, .keyboardKeyWidth = 31, - .keyboardKeyHeight = 50, + .keyboardKeyHeight = 40, .keyboardKeySpacing = 0, + .keyboardBottomKeyHeight = 35, + .keyboardBottomKeySpacing = 5, .keyboardBottomAligned = true, - .keyboardCenteredText = true}; + .keyboardCenteredText = false, + .keyboardVerticalOffset = -7, + .keyboardTextFieldWidthPercent = 85, + .keyboardWidthPercent = 90}; } class Lyra3CoversTheme : public LyraTheme { diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 56903f892..a28349ef1 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -578,20 +578,67 @@ void LyraTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou renderer.displayBuffer(HalDisplay::FAST_REFRESH); } -void LyraTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const { +void LyraTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode, + int contentStartX, int contentWidth) const { int lineY = rect.y + rect.height + renderer.getLineHeight(UI_12_FONT_ID) + LyraMetrics::values.verticalSpacing; - int lineW = textWidth + hPaddingInSelection * 2; - renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, lineY, 3); + const int thickness = cursorMode ? 3 : 1; + if (contentWidth > 0) { + renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth, lineY, thickness, true); + } else { + int lineW = textWidth + hPaddingInSelection * 2; + renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, lineY, thickness, + true); + } } -void LyraTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, - const bool isSelected) const { +void LyraTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, + const char* secondaryLabel, const KeyboardKeyType keyType, + const bool inactiveSelection) const { if (isSelected) { - renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::Black); + if (inactiveSelection) { + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::LightGray); + } else if (keyType == KeyboardKeyType::Disabled) { + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::LightGray); + } else { + renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::Black); + } + } else if (keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Del || + keyType == KeyboardKeyType::Space || keyType == KeyboardKeyType::Ok || + keyType == KeyboardKeyType::Disabled) { + renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cornerRadius, true); } + const bool invert = isSelected && !inactiveSelection; + + if (keyType == KeyboardKeyType::Space) { + const int lineHalfWidth = rect.width * 3 / 10; + const int centerX = rect.x + rect.width / 2; + const int lineY = rect.y + rect.height / 2 + 3; + renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); + return; + } + + if (keyType == KeyboardKeyType::Del) { + const int centerX = rect.x + rect.width / 2; + const int centerY = rect.y + rect.height / 2; + const int arrowLen = rect.width / 4; + const int arrowHead = arrowLen / 2; + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, + !invert); + renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, + !invert); + return; + } + + const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, label); const int textX = rect.x + (rect.width - textWidth) / 2; const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; - renderer.drawText(UI_12_FONT_ID, textX, textY, label, !isSelected); + renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); + + if (hasSecondary) { + const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); + renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - 1, rect.y, secondaryLabel, !invert); + } } diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 4e20390e1..b98e52974 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -32,10 +32,15 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, .keyboardKeyWidth = 31, - .keyboardKeyHeight = 50, + .keyboardKeyHeight = 40, .keyboardKeySpacing = 0, + .keyboardBottomKeyHeight = 35, + .keyboardBottomKeySpacing = 5, .keyboardBottomAligned = true, - .keyboardCenteredText = true}; + .keyboardCenteredText = false, + .keyboardVerticalOffset = -7, + .keyboardTextFieldWidthPercent = 85, + .keyboardWidthPercent = 90}; } class LyraTheme : public BaseTheme { @@ -66,7 +71,10 @@ class LyraTheme : public BaseTheme { void drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const; Rect drawPopup(const GfxRenderer& renderer, const char* message) const override; void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const override; - void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const override; - void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const override; + void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, + int contentStartX = 0, int contentWidth = 0) const override; + void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, + const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, + bool inactiveSelection = false) const override; bool showsFileIcons() const override { return true; } };