From 875eb29215d354bb5fc313e3afa57dea3d6c1248 Mon Sep 17 00:00:00 2001 From: pablohc Date: Sat, 18 Apr 2026 23:12:56 +0200 Subject: [PATCH] refactor: redesign on-screen keyboard (#1644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete redesign of the on-screen keyboard (used for WiFi password, KOReader, Calibre URLs) with improved layout, navigation, visual style, and new input features: **cursor mode** for text navigation, **password mode** with visibility toggle, and **URL mode** with pre-defined snippets. |**master** | **PR #1644** | |----------|-------------| | image | image | | **master** | **PR #1644** | |----------|-------------| | image | image | | ABC Mode | Symbol Mode | URL Mode | |----------|-------------|----------| | image | image | image | | Cursor Mode | Password Toggle | |-------------|-----------------| | image | image | - Reduced from 13/11/10 columns per row to **10 uniform columns** across all rows - Keyboard now uses **90% of screen width** (was ~66%) - Row 0: Numbers `1-9, 0` with secondary symbols (`!@#$%^&*()`) - Rows 1-3: Standard QWERTY letters - Bottom row: `shift` | `#@!` | `___` | `←` | `OK` - New mode toggle key `#@!` / `abc` switches between letter and symbol layouts - Symbol layout: 4 rows (numbers, inverted symbols, paired symbols, loose symbols) - Covers all 95 printable ASCII characters - **No secondary hints, no long-press** in symbol mode (simple and direct) - SHIFT key remains visible but **disabled** in symbol mode - In `InputType::Url`, the Space key becomes a **URL toggle** button - Activating URL mode replaces the 4 content rows with a **3×3 grid of URL snippets**: - Col 0 (protocols): `https://`, `http://`, `/opds` - Col 1 (hosts/ports): `www.`, `192.168.`, `:8080` - Col 2 (domains): `.com`, `.org`, `.net` - Snippets are inserted as full strings at the cursor position - URL mode **persists** after inserting a snippet (does not auto-deactivate) - Column alignment: col 0 over ABC, col 1 over URL, col 2 over Del - Up/Down navigation maps `bottomCol - 1` / `urlCol + 1` - SHIFT disabled in URL mode - SpecMode (`abc`) exits URL mode back to ABC - SpecSpace (`URL`) toggles URL mode on/off; selection always stays on the URL button - Button styled with `KeyboardKeyType::Mode` for consistent outline - **Enter**: Long-press Up (500ms) while in keyboard mode - **Exit**: Short-press Down while in cursor mode (resets `passwordVisible`, clears toggle position) - **Navigate**: Left/Right move cursor position within text (one position per press, no continuous repeat) - **Visual**: - Keyboard mode: underline cursor (2px line + serifs) - Cursor mode: inverted block cursor (black fill + white character) - Block width adapts to the actual character width under cursor (minimum 6px for narrow chars like space) - Block position includes inter-character kerning offset for correct alignment (calculated via string-difference: `getTextWidth(before+char) - getTextWidth(before) - getTextWidth(char)`) - End-of-text: thin 6px block - Password hidden: 3-part drawing (Part 1 + block + Part 3) to prevent block overflow onto `*` characters - Toggle position: caret ("I") cursor at saved position, `[abc]`/`[***]` label with inverted selection - **Inactive key styling**: - BaseTheme: 2px outline rectangle - LyraTheme: gray filled rounded rectangle (`Color::LightGray`) - **Password toggle position**: in cursor mode (Password only), Hold Right (500ms) enters toggle — caret cursor shows saved position, `[abc]`/`[***]` label becomes selected. Press Confirm to toggle `passwordVisible`. Press Left to restore cursor to saved position. Right from toggle is a no-op. Down from toggle exits to keyboard. - `cursorPos` persists between keyboard and cursor modes - `InputType::Password` enum replaces `bool isPassword` parameter - Text is masked with `*` except for one revealed character: - Keyboard mode: reveals character at `cursorPos - 1` - Cursor mode: no reveal in display text (block cursor draws actual char directly) - **Toggle `[abc]`/`[***]`**: accessible via cursor mode — Hold Right (500ms) enters toggle position, Confirm toggles visibility, Left exits back to cursor. Caret ("I") shown at saved position while in toggle. - `passwordVisible` resets to `false` when exiting cursor mode - **Long-press Del (1.5s)**: clears all text and resets cursor to 0 - Replaced `bool isPassword` constructor parameter with `enum class InputType { Text, Password, Url }` - Callers updated: `WifiSelectionActivity`, `KOReaderSettingsActivity`, `CalibreSettingsActivity` - `"Tips:"` header followed by context-sensitive hints, centered between text field underline and keyboard as a block - ABC mode: `"Hold SELECT for UPPERCASE or secondary char"` (shift ON: `"lowercase"` variant) + `"Hold DEL to clear all text"` (only if text not empty) - ABC + `InputType::Url`: same + `"Press URL for snippets"` - Symbol mode: `"Hold DEL to clear all text"` (only if text not empty) - URL mode: `"Press ABC to exit URL mode"` + `"Hold DEL to clear all text"` (only if text not empty) - Cursor mode: `"Press DOWN to return to keyboard"` - **Phase 1**: `"Hold UP to edit entry"` — shown after 2× DEL press, auto-hides after 4s, positioned below underline - **Phase 2**: `"Press < or > to move cursor"` + dynamic password toggle hint — shown when entering cursor mode, positioned below underline, visible until exit - When `!passwordVisible`: `"Hold > then press [abc] to show password"` - When `passwordVisible`: `"Hold > then press [***] to hide password"` - When in toggle position: `"Press < to return to cursor position"` - Holding Confirm (>500ms) inserts the **alternative character** instead of the primary - Letters: long-press inserts opposite case (e.g., `a`→`A`, `A`→`a`) - Numbers/symbols (row 0): long-press inserts secondary (e.g., `0`→`)`, `)`→`0`) - Only active in ABC mode; disabled in Symbol mode and URL mode - **`InputType::Url`**: Hold SELECT on ABC rows 1+ (letters) returns primary character only (same as short press). Row 0 (symbols) still returns secondary character on Hold SELECT. - Reduced from 3 states (shift/SHIFT/LOCK) to **2 sticky states** (shift/SHIFT) - Shift stays active after typing until manually toggled off - Label: `shift` (off) / `SHIFT` (on) - `enum class SpecialKeyType { Shift, Mode, Space, Del, Ok }` replaces plain `enum` (`SpecShift`, `SpecMode`, etc.) for type safety - All switch cases updated to `SpecialKeyType::*` with `static_cast()` for array indexing - `onExit()` reverted to simple `Activity::onExit()` call (half-refresh removed) - **Bottom row column mapping**: navigating up/down between content rows and bottom row uses `col/2` and `col*2` formulas for consistent positioning (10 cols ↔ 5 cols) - **URL mode column mapping**: `bottomCol - 1` / `urlCol + 1` (3 cols ↔ 5 cols) - **Wrap-around**: row 0 → up → bottom row and bottom row → down → row 0 both apply correct column mapping - **Space key**: underscore-style horizontal line (60% of key width, 3px thick) - **Delete key**: arrow `←` drawn with lines (3px thick) instead of "DEL" text - **Secondary label** (ABC row 0): small hint in top-right corner with separation from primary number - **BaseTheme**: selection uses **inverted fill** (black rect + white text) instead of `[bracket]` markers - **BaseTheme**: text field brackets drawn as **stretchable lines** that adapt to multi-line input (1px normal, 3px cursor mode) - **LyraTheme**: text field uses **fixed-width underline** (16px margins, 8px each side) instead of stretchable line (2px normal, 3px cursor mode) - **Both themes**: special keys (shift, mode, space, del, OK) have bordered/bordered-rounded rectangles - **Font size**: keyboard uses `UI_12_FONT_ID` in both themes (was `UI_10` in Base) - **Key height**: 40px in all themes for better proportions - **Layout unification**: text and password toggle are left-aligned in all themes (`keyboardCenteredText = false` for Lyra/Lyra3Covers) - **`primaryOffset` removed**: dead code eliminated from BaseTheme and LyraTheme `drawKeyboardKey` - `keyboardVerticalOffset`: per-theme vertical adjustment of keyboard position - Base: `-13`, Lyra: `-7` - `keyboardBottomKeySpacing`: independent spacing for bottom row keys - Base: `5`, Lyra: `5` - Bottom-aligned keyboard in both themes for consistent vertical positioning - Bottom row total width calculated to match content rows width (10-col based, consistent across modes) - 4px extra gap between content rows and bottom row when `bkSpacing > 0` - `keyboardCenteredText`: `false` for all themes (unified left-aligned text) - **State reset on re-entry**: `onEnter()` resets all mutable state (`symMode`, `urlMode`, `cursorMode`, `togglePos`, `passwordVisible`, `shiftState`, `selectedRow`, `selectedCol`, `rightHeld`, `rightLongHandled`, `savedCursorPos`, `rightStartCursorPos`, `delPressCount`, `hintVisible`, `hintShowTime`) — prevents stale state when re-entering the keyboard - **Bounds checking**: `insertChar`/`insertString` clamp `cursorPos` to `text.length()` before inserting - **Empty string guard**: `insertString` returns early on empty string - **`std::string::npos`**: used instead of `SIZE_MAX` for size_t sentinel (proper C++ idiom) - **`` header**: included for `std::max` | File | Changes | |------|---------| | `src/activities/util/KeyboardEntryActivity.h` | `InputType` enum, `KeyDef` struct, 10-col layouts, cursor/password/URL/toggle state, hints (`delPressCount`, `hintVisible`, `hintShowTime`), held vars (`rightHeld`, `rightLongHandled`, `savedCursorPos`, `rightStartCursorPos`), `mapColContentBottom` helper | | `src/activities/util/KeyboardEntryActivity.cpp` | Complete rewrite: layout rendering, symbol/cursor/password/URL modes, toggle position, long-press, contextual tips, hint phases, block cursor kerning alignment, defensive bounds checks, state reset | | `src/components/themes/BaseTheme.h` | `KeyboardKeyType` enum, new `drawTextField`/`drawKeyboardKey` signatures, `keyboardVerticalOffset`, `keyboardBottomKeySpacing` metrics | | `src/components/themes/BaseTheme.cpp` | Redesigned `drawTextField` (stretchable brackets), `drawKeyboardKey` (inverted selection, space/delete graphics, secondary label, inactive selection), removed `primaryOffset` dead code | | `src/components/themes/lyra/LyraTheme.h` | Override signatures, `keyboardVerticalOffset`, `keyboardBottomKeySpacing`, `keyboardKeyHeight` adjustments | | `src/components/themes/lyra/LyraTheme.cpp` | `drawTextField` (fixed underline), `drawKeyboardKey` (rounded rects for special keys, space/delete graphics, secondary label, inactive selection), removed `primaryOffset` dead code | | `src/components/themes/lyra/Lyra3CoversTheme.h` | `keyboardCenteredText = false`, `keyboardVerticalOffset = -7`, inherits Lyra overrides | | `src/activities/network/WifiSelectionActivity.cpp` | `bool isPassword` → `InputType::Password` | | `src/activities/settings/KOReaderSettingsActivity.cpp` | `bool isPassword` → `InputType::Text`/`InputType::Password`/`InputType::Url` | | `src/activities/settings/CalibreSettingsActivity.cpp` | `bool isPassword` → `InputType::Text`/`InputType::Password`/`InputType::Url` | - **API change**: Constructor parameter changed from `bool isPassword` to `InputType inputType` (default `InputType::Text`) - **All callers updated**: WiFi, KOReader, and Calibre integrations migrated to new `InputType` enum - [x] Empty input → press OK (submit empty string) - [x] Back button → cancel (no text returned) - [x] Pre-filled initial text (e.g., editing existing WiFi password) - [x] Password mode: text masked with `*` characters, one character revealed - [x] Delete on empty text (no crash) - [x] Very long text near maxLength limit - [x] URL with path and port (~60 chars) - [x] Multi-line text wrapping in input field - [x] Space insert in middle of text (cursor mode) - [x] Delete last character repeatedly - [ ] Type all 95 printable ASCII characters - [x] ABC → #@! preserves typed text and cursor position - [x] #@! → ABC preserves typed text and cursor position - [x] Shift state preserved when switching modes - [x] Switch modes multiple times rapidly - [x] Shift OFF → type letter → inserts lowercase, shift stays OFF - [x] Shift ON → type letter → inserts uppercase, shift stays ON - [x] Shift ON → type number → inserts symbol, shift stays ON - [x] Shift ON → navigate rows → shift stays ON - [x] Shift ON → switch to #@! → shift shows "shift" (disabled) - [x] Shift ON → switch to ABC → shift state preserved - [x] Shift ON → switch to URL → shift shows "shift" (disabled) - [x] Shift disabled in URL mode: pressing shift does nothing - [x] Long-press letter with shift OFF → inserts uppercase - [x] Long-press letter with shift ON → inserts lowercase - [x] Long-press number → inserts secondary symbol - [x] Long-press symbol (row 0) → inserts opposite (number) - [x] Long-press key without secondary (e.g., `-`, `=` in rows 2-3) → inserts primary character on release - [x] Long-press on special keys (shift, mode, space, del, ok) → no alternative inserted - [x] Long-press in #@! mode → no effect (disabled) - [x] Long-press in URL mode → no effect (disabled) - [x] Long-press number in row 0 with InputType::Url → inserts secondary symbol (same as non-URL) - [x] Short press after cancelled long-press → normal behavior - [x] Long-press at maxLength → no character inserted - [x] Long-press Del (1.5s) → clears all text - [x] Long-press Up → enters cursor mode - [x] Short-press Down → exits cursor mode (resets passwordVisible) - [x] Left/Right navigate within text - [x] Left at position 0 → no movement - [x] Right at end of text → no movement in Text mode, enters toggle in Password mode (Hold Right) - [x] Block cursor visual: correct width for character, thin block at end - [x] Underline cursor visual (keyboard mode): correct position with serifs - [x] Inactive key styling: outline (Base) or gray fill (Lyra) on selected key - [x] Typing with cursor mid-text → inserts at cursor position - [x] Deleting with cursor mid-text → deletes character before cursor - [x] Exit cursor mode → type at cursor position (inserts mid-text, not at end) - [x] Exit cursor mode from toggle → cursor at saved position (not end of text) - [x] Masked text with one revealed character at `cursorPos - 1` - [x] Cursor mode: block shows actual character, display text all `*` - [x] Toggle `[abc]`/`[***]`: Hold Right (500ms) in cursor mode enters toggle, Confirm toggles visibility, Left exits back to cursor - [x] Exiting cursor mode resets `passwordVisible` to false - [x] Long-press Del clears all text - [x] URL toggle activates/deactivates URL mode - [x] URL button stays selected after toggle (both on and off) - [x] Deactivating URL mode returns to ABC (not SYM) - [x] 3×3 snippet grid displays correctly - [x] Column alignment: col 0 over ABC, col 1 over URL, col 2 over Del - [x] Snippet insertion: inserts full string at cursor position - [x] URL mode persists after snippet insertion - [x] Shift disabled in InputType::Url - [x] SpecMode (`abc`) exits URL mode to ABC - [x] Up/Down navigation between URL grid and bottom row - [x] Enter keyboard → activate URL mode → exit → re-enter → URL mode OFF - [x] Enter keyboard → switch to SYM → exit → re-enter → ABC mode - [x] Enter keyboard → enter cursor mode → exit → re-enter → keyboard mode - [x] Enter keyboard → enter toggle pos → exit → re-enter → togglePos OFF - [x] Enter keyboard → activate shift → exit → re-enter → shift OFF - [x] Enter password keyboard → toggle password visible → exit → re-enter → password hidden - [x] Left/right wrap-around within content rows - [x] Left/right wrap-around within bottom row - [x] Up from row 0 → bottom row (correct column mapping) - [x] Down from bottom row → row 0 (correct column mapping) - [x] Up from bottom row → last content row (correct column) - [x] Down from last content row → bottom row (correct column) - [x] Navigate horizontally in bottom row, then up → correct content column - [x] Navigate horizontally in bottom row, then down (wrap) → correct content column - [x] Secondary hints only on ABC row 0 - [x] No secondary hints in #@! mode or URL mode - [x] No secondary hints on letter rows (1-3) - [x] Space bar: horizontal line centered, not touching edges - [x] Delete: arrow `←` drawn correctly - [x] Selected key: inverted colors (black fill, white text) - [x] All special keys have border rectangles - [x] Fixed underline in text field (Both themes) - [x] Mode key label: `#@!` in ABC mode, `abc` in symbol mode, `abc` in URL mode - [x] URL key label: `URL` (only in InputType::Url), styled same as other bottom keys - [x] Shift label: `shift` when OFF, `SHIFT` when ON, `shift` when disabled (SYM/URL) - [x] Both themes: bottom row total width matches content rows width - [x] URL snippet grid centered over ABC/URL/Del buttons - [ ] Base Theme on X3 - [x] Base Theme on X4 - [ ] Lyra Theme on X3 - [x] Lyra Theme on X4 - [ ] Lyra Extended Theme on X3 - [x] Lyra Extended Theme on X4 - [x] Hold Right > 500ms in cursor mode (Password) → enters toggle, caret visible at saved position - [x] Short-press Right in cursor mode (Password) → advances cursor 1 position, does not jump to toggle - [x] Short-press Left in cursor mode (Password) → moves cursor left 1 position, from toggle returns to saved position - [x] Confirm in toggle → toggles `passwordVisible` - [x] Left from toggle → returns to saved position, caret disappears, block cursor appears - [x] Right from toggle → no-op - [x] Down from toggle → exits to keyboard, cursor at saved position - [x] Hold Right in cursor mode (InputType::Text) → no effect - [x] Hold Right in cursor mode (InputType::Url) → no effect - [x] Hold Right < 500ms released in cursor mode (Password) → short press, advances cursor 1 - [x] No continuous repeat when holding Left or Right in cursor mode - [x] In toggle: caret "I" visible at saved cursor position - [x] Character under cursor visible (no gap) in password not-visible mode - [x] Character under cursor visible in password visible mode - [x] `[abc]`/`[***]` label with inverted selection in toggle - [x] `"Tips:"` header centered above contextual hints - [x] Single tip → `"Tips:"` + one line - [x] Multiple tips → `"Tips:"` + multiple lines, all centered as block - [x] No tips shown when not applicable (e.g., ABC with empty text and non-URL) - [x] `"UPPERCASE"` shown when shift OFF - [x] `"lowercase"` shown when shift ON - [x] `"secondary char"` shown for InputType::Url - [x] 2× DEL → Phase 1 appears ("Hold UP to edit entry") - [x] Phase 1 auto-hides after 4s - [x] Phase 2 appears when entering cursor mode ("Press < or > to move cursor") - [x] Phase 2 shows "Hold > then press [abc] to show password" when `!passwordVisible` - [x] Phase 2 shows "Hold > then press [***] to hide password" when `passwordVisible` - [x] Phase 2 shows "Press < to return to cursor position" when in toggle - [x] Phase 2 disappears when exiting cursor mode - [x] Hold SELECT on letter rows (rows 1+) with InputType::Url → same character as short press - [x] Hold SELECT on row 0 with InputType::Url → secondary character works normally - [x] Number row order: 1-9, 0 left to right - [x] `(` and `)` are adjacent (positions 8 and 9) via secondary labels - [x] Long-press on row 0 returns correct secondary symbols in new order - [x] SYM row 1: `(` and `)` also adjacent (positions 8 and 9) - [x] Block cursor correctly positioned for consecutive spaces (kerning offset applied) - [x] Block cursor correctly positioned for mixed characters (letters, numbers, symbols) - [x] Block width minimum 6px for narrow characters (space) — visible as block, not thin line - [x] Password hidden: 3-part drawing prevents block overflow onto `*` characters - [x] Password visible: block post-loop draws correctly on continuous text (no 3-part needed) - [x] End-of-text block: thin 6px block at correct position - [x] WiFi password entry (connect to network) - [ ] KOReader username, password, and sync server URL - [ ] Calibre OPDS URL, username, and password - [ ] Calibre OPDS URL: empty → opens with "https://" prefilled - [ ] Calibre OPDS URL: type "http://" or "https://" only → saved as empty - [ ] Calibre OPDS URL: type full URL → saved correctly - [ ] Calibre OPDS URL: existing URL → opens with existing URL (not "https://" prefill) --- src/activities/util/KeyboardEntryActivity.cpp | 350 ++++++++++++------ src/activities/util/KeyboardEntryActivity.h | 30 +- 2 files changed, 247 insertions(+), 133 deletions(-) diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 653d98e2..7f25f685 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -1,14 +1,15 @@ #include "KeyboardEntryActivity.h" +#include #include +#include + #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" -const char* KeyboardEntryActivity::shiftLabel(int shiftState) { - return shiftState ? tr(STR_SHIFT_CAPS) : tr(STR_SHIFT); -} +const char* const KeyboardEntryActivity::shiftString[2] = {"shift", "SHIFT"}; void KeyboardEntryActivity::onEnter() { Activity::onEnter(); @@ -16,16 +17,18 @@ void KeyboardEntryActivity::onEnter() { symMode = false; urlMode = false; cursorMode = false; + togglePos = false; passwordVisible = false; shiftState = 0; selectedRow = 0; selectedCol = 0; - confirmHeld = false; - confirmLongHandled = false; - upHeld = false; - upLongHandled = false; - downHeld = false; - downLongHandled = false; + delPressCount = 0; + hintVisible = false; + hintShowTime = 0; + rightHeld = false; + rightLongHandled = false; + savedCursorPos = 0; + rightStartCursorPos = 0; requestUpdate(); } @@ -56,7 +59,8 @@ char KeyboardEntryActivity::getSelectedChar() const { } char KeyboardEntryActivity::getAlternativeChar() const { - if (symMode) return '\0'; + if (symMode || urlMode) return '\0'; + if (inputType == InputType::Url && selectedRow > 0) return '\0'; const KeyDef(*layout)[COLS] = abcLayout; @@ -92,15 +96,21 @@ void KeyboardEntryActivity::insertString(const std::string& str) { bool KeyboardEntryActivity::handleKeyPress() { if (isBottomRow(selectedRow)) { switch (static_cast(selectedCol)) { - case SpecShift: - if (urlMode) return true; + 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 SpecMode: { + case SpecialKeyType::Mode: { + delPressCount = 0; + hintVisible = false; if (urlMode) { urlMode = false; symMode = false; + selectedRow = getTotalRowCount() - 1; + selectedCol = static_cast(SpecialKeyType::Mode); requestUpdate(); return true; } @@ -114,26 +124,35 @@ bool KeyboardEntryActivity::handleKeyPress() { } return true; } - case SpecSpace: + case SpecialKeyType::Space: + delPressCount = 0; + hintVisible = false; if (inputType == InputType::Url) { urlMode = !urlMode; if (urlMode) { symMode = false; } selectedRow = getTotalRowCount() - 1; - selectedCol = SpecSpace; + selectedCol = static_cast(SpecialKeyType::Space); requestUpdate(); } else { return insertChar(' '); } return true; - case SpecDel: + 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 SpecOk: + case SpecialKeyType::Ok: + delPressCount = 0; + hintVisible = false; onComplete(text); return false; default: @@ -142,6 +161,8 @@ bool KeyboardEntryActivity::handleKeyPress() { } if (urlMode) { + delPressCount = 0; + hintVisible = false; const int idx = selectedCol + selectedRow * 3; if (idx < URL_SNIPPET_COUNT) { insertString(urlSnippets[idx]); @@ -149,6 +170,9 @@ bool KeyboardEntryActivity::handleKeyPress() { return true; } + delPressCount = 0; + hintVisible = false; + return insertChar(getSelectedChar()); } @@ -174,6 +198,8 @@ void KeyboardEntryActivity::loop() { mappedInput.getHeldTime() > LONG_PRESS_MS) { cursorMode = true; upLongHandled = true; + hintVisible = true; + hintShowTime = millis(); requestUpdate(); } @@ -198,11 +224,10 @@ void KeyboardEntryActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { downHeld = true; if (cursorMode) { - if (cursorPos > text.length()) { - cursorPos = text.length(); - } + togglePos = false; passwordVisible = false; cursorMode = false; + hintVisible = false; downLongHandled = true; requestUpdate(); } else { @@ -229,41 +254,72 @@ void KeyboardEntryActivity::loop() { } buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [this] { - if (cursorMode) { - if (cursorPos > 0) { - cursorPos--; - requestUpdate(); - } - return; - } + if (cursorMode) return; int maxCol = isBottomRow(selectedRow) ? BOTTOM_KEY_COUNT - 1 : getContentColCount() - 1; selectedCol = ButtonNavigator::previousIndex(selectedCol, maxCol + 1); requestUpdate(); }); - buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [this] { + if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (cursorMode) { - if (cursorPos < text.length()) { - cursorPos++; + if (togglePos) { + cursorPos = savedCursorPos; + togglePos = false; requestUpdate(); - } else if (cursorPos == text.length() && inputType == InputType::Password) { - cursorPos = text.length() + 1; + } else if (cursorPos > 0) { + cursorPos--; requestUpdate(); } - return; } + } + + 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 == SpecDel) { + mappedInput.getHeldTime() > DEL_LONG_PRESS_MS && isBottomRow(selectedRow) && + selectedCol == static_cast(SpecialKeyType::Del)) { text.clear(); cursorPos = 0; confirmLongHandled = true; @@ -271,7 +327,7 @@ void KeyboardEntryActivity::loop() { } if (confirmHeld && !confirmLongHandled && mappedInput.isPressed(MappedInputManager::Button::Confirm) && - mappedInput.getHeldTime() > LONG_PRESS_MS && !cursorMode && inputType == InputType::Text && !urlMode) { + mappedInput.getHeldTime() > LONG_PRESS_MS) { char alt = getAlternativeChar(); if (alt != '\0') { insertChar(alt); @@ -285,8 +341,7 @@ void KeyboardEntryActivity::loop() { if (handleKeyPress()) { requestUpdate(); } - } else if (confirmHeld && !confirmLongHandled && cursorMode && inputType == InputType::Password && - cursorPos > text.length()) { + } else if (confirmHeld && !confirmLongHandled && cursorMode && inputType == InputType::Password && togglePos) { passwordVisible = !passwordVisible; requestUpdate(); } @@ -297,24 +352,26 @@ void KeyboardEntryActivity::loop() { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onCancel(); } + + if (hintVisible && !cursorMode && millis() - hintShowTime > 4000) { + hintVisible = false; + requestUpdate(); + } } void KeyboardEntryActivity::render(RenderLock&&) { renderer.clearScreen(); - const Rect contentRect = UITheme::getContentRect(renderer, true, true); - const int pageWidth = contentRect.width; - const int contentX = contentRect.x; - const int contentY = contentRect.y; + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); const auto& metrics = UITheme::getInstance().getMetrics(); - GUI.drawHeader(renderer, Rect{contentX, contentY + metrics.topPadding, pageWidth, metrics.headerHeight}, - title.c_str()); + 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 = contentY + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + + const int inputStartY = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.verticalSpacing * 4 + metrics.keyboardVerticalOffset; - int inputHeight = lineHeight; + int inputHeight = 0; std::string displayText; if (inputType == InputType::Password && !passwordVisible) { @@ -322,7 +379,7 @@ void KeyboardEntryActivity::render(RenderLock&&) { if (cursorMode) { revealPos = text.length(); // no reveal in displayText; block draws actual char directly } else { - revealPos = (text.length() > 0 && cursorPos > 0) ? cursorPos - 1 : 0; + revealPos = (text.length() > 0 && cursorPos > 0) ? cursorPos - 1 : std::string::npos; } displayText = text; for (size_t i = 0; i < displayText.length(); i++) { @@ -335,10 +392,12 @@ void KeyboardEntryActivity::render(RenderLock&&) { } const bool isPassword = (inputType == InputType::Password); - const int margin = metrics.contentSidePadding; - const int extraMargin = 10; - const int effectiveMargin = margin + extraMargin; - const int toggleGap = isPassword ? 8 : 0; + 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 @@ -347,64 +406,16 @@ void KeyboardEntryActivity::render(RenderLock&&) { const int maxLineWidth = textAreaWidth; const bool centerText = metrics.keyboardCenteredText; - const int keyHeight = metrics.keyboardKeyHeight; - const int bottomKeyHeight = metrics.keyboardBottomKeyHeight; - const int keySpacing = metrics.keyboardKeySpacing; - const int contentCols = getContentColCount(); - const int keyWidth = (pageWidth * 95 / 100 - (contentCols - 1) * keySpacing) / contentCols; - const int leftMargin = contentX + (pageWidth - (contentCols * keyWidth + (contentCols - 1) * keySpacing)) / 2; - - const int bottomRowGap = metrics.keyboardBottomKeySpacing > 0 ? 4 : 0; - const int keyboardStartY = metrics.keyboardBottomAligned - ? contentY + contentRect.height - metrics.verticalSpacing - - (keyHeight + keySpacing) * getContentRowCount() - bottomKeyHeight - - bottomRowGap + metrics.keyboardVerticalOffset - : inputStartY + inputHeight + lineHeight + metrics.verticalSpacing; - - const char* helpTexts[3]; - if (inputType == InputType::Url) { - helpTexts[0] = tr(STR_KEYBOARD_HELP_URL_LINE_1); - helpTexts[1] = tr(STR_KEYBOARD_HELP_URL_LINE_2); - helpTexts[2] = tr(STR_KEYBOARD_HELP_URL_LINE_3); - } else if (cursorMode) { - helpTexts[0] = tr(STR_KEYBOARD_HELP_CURSOR_LINE_1); - helpTexts[1] = tr(STR_KEYBOARD_HELP_CURSOR_LINE_2); - helpTexts[2] = tr(STR_KEYBOARD_HELP_CURSOR_LINE_3); - } else if (inputType == InputType::Password) { - helpTexts[0] = tr(STR_KEYBOARD_HELP_PASSWORD_LINE_1); - helpTexts[1] = tr(STR_KEYBOARD_HELP_PASSWORD_LINE_2); - helpTexts[2] = tr(STR_KEYBOARD_HELP_PASSWORD_LINE_3); - } else { - helpTexts[0] = tr(STR_KEYBOARD_HELP_DEFAULT_LINE_1); - helpTexts[1] = tr(STR_KEYBOARD_HELP_DEFAULT_LINE_2); - helpTexts[2] = tr(STR_KEYBOARD_HELP_DEFAULT_LINE_3); - } - - const int helpGap = keyboardStartY - (inputStartY + inputHeight); - const int helpLineHeight = renderer.getLineHeight(SMALL_FONT_ID); - const int maxHelpLines = std::max(0, helpGap / helpLineHeight); - const int helpLineCount = std::min(3, maxHelpLines); - const int helpBodyHeight = helpLineCount * helpLineHeight; - const int helpTop = inputStartY + inputHeight + std::max(0, (helpGap - helpBodyHeight) / 2); - const int helpLineWidth = pageWidth - 2 * metrics.contentSidePadding; - for (int i = 0; i < helpLineCount; ++i) { - const char* helpText = helpTexts[i]; - const std::string line = renderer.truncatedText(SMALL_FONT_ID, helpText, helpLineWidth, EpdFontFamily::ITALIC); - renderer.drawText(SMALL_FONT_ID, contentX + metrics.contentSidePadding, helpTop + i * helpLineHeight, line.c_str(), - true, EpdFontFamily::ITALIC); - } - - int cursorCharWidth; + int cursorCharWidth = 6; if (cursorPos < text.length()) { - cursorCharWidth = renderer.getTextWidth(UI_12_FONT_ID, text.substr(cursorPos, 1).c_str()); - } else { - cursorCharWidth = 6; + int w = renderer.getTextWidth(UI_12_FONT_ID, text.substr(cursorPos, 1).c_str()); + if (w > cursorCharWidth) cursorCharWidth = w; } int lineStartIdx = 0; int lineEndIdx = displayText.length(); int textWidth = 0; - int cursorPixelX = contentX + metrics.contentSidePadding; + int cursorPixelX = effectiveMargin; int cursorLineY = inputStartY; bool cursorDrawn = false; @@ -423,22 +434,25 @@ void KeyboardEntryActivity::render(RenderLock&&) { beforeCursor = displayText.substr(lineStartIdx, cursorPos - lineStartIdx); } int beforeWidth = renderer.getTextWidth(UI_12_FONT_ID, beforeCursor.c_str()); - if (centerText) { - cursorPixelX = contentX + effectiveMargin + (maxLineWidth - textWidth) / 2 + beforeWidth; - } else { - cursorPixelX = contentX + effectiveMargin + beforeWidth; + 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 (cursorPos == static_cast(lineEndIdx) && cursorPos == displayText.length()) { - cursorPixelX += 2; + if (centerText) { + cursorPixelX = effectiveMargin + (maxLineWidth - textWidth) / 2 + beforeWidth + kernOffset; + } else { + cursorPixelX = effectiveMargin + beforeWidth + kernOffset; } cursorLineY = inputStartY + inputHeight; cursorDrawn = true; isCursorLine = true; } - const int lineStartX = - contentX + (centerText ? effectiveMargin + (maxLineWidth - textWidth) / 2 : effectiveMargin); - if (isCursorLine && cursorMode && isPassword && !passwordVisible) { + 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 @@ -468,18 +482,18 @@ void KeyboardEntryActivity::render(RenderLock&&) { } const int fieldWidth = (inputHeight > 0) ? maxLineWidth : textWidth; - const int lineMargin = margin + extraMargin - 5; - GUI.drawTextField(renderer, Rect{contentX, inputStartY, pageWidth, inputHeight}, fieldWidth, cursorMode, lineMargin, + const int lineMargin = effectiveMargin; + GUI.drawTextField(renderer, Rect{0, inputStartY, pageWidth, inputHeight}, fieldWidth, cursorMode, lineMargin, pageWidth - 2 * lineMargin); - if (cursorMode && cursorPos <= displayText.length()) { + 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 (!cursorMode && cursorPos <= displayText.length()) { + } else if (cursorPos <= displayText.length()) { static constexpr int serifW = 3; const int cX = cursorPixelX; const int cY = cursorLineY; @@ -494,9 +508,9 @@ void KeyboardEntryActivity::render(RenderLock&&) { if (isPassword) { const char* toggleLabel = passwordVisible ? "[***]" : "[abc]"; const int toggleWidth = renderer.getTextWidth(UI_12_FONT_ID, toggleLabel); - const int toggleX = contentX + pageWidth - effectiveMargin - toggleWidth; + const int toggleX = pageWidth - effectiveMargin - toggleWidth; const int toggleY = inputStartY + inputHeight; - const bool toggleSelected = cursorMode && cursorPos > text.length(); + const bool toggleSelected = cursorMode && togglePos; if (toggleSelected) { renderer.fillRect(toggleX - 2, toggleY, toggleWidth + 5, lineHeight + 3, true); @@ -506,17 +520,108 @@ void KeyboardEntryActivity::render(RenderLock&&) { } } + 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 - + (keyHeight + keySpacing) * getContentRowCount() - bottomKeyHeight - + bottomRowGap + metrics.keyboardVerticalOffset + : inputStartY + inputHeight + lineHeight + metrics.verticalSpacing; + + 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 (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); + } + + 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 { + 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); + } + } + } + const int bkSpacing = metrics.keyboardBottomKeySpacing; - const int contentTotalWidth = - COLS * ((pageWidth * 95 / 100 - (COLS - 1) * keySpacing) / COLS) + (COLS - 1) * keySpacing; + 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 = - contentX + (pageWidth - (BOTTOM_KEY_COUNT * bottomKeyWidth + (BOTTOM_KEY_COUNT - 1) * bkSpacing)) / 2; + (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 + SpecSpace * (bottomKeyWidth + bkSpacing) + bottomKeyWidth / 2; + const int urlCenterX = + bottomLeftMargin + static_cast(SpecialKeyType::Space) * (bottomKeyWidth + bkSpacing) + bottomKeyWidth / 2; urlLeftMargin = urlCenterX - urlTotalWidth / 2; } @@ -566,7 +671,8 @@ void KeyboardEntryActivity::render(RenderLock&&) { const char* label; }; const BottomKeyInfo bottomKeys[BOTTOM_KEY_COUNT] = { - {KeyboardKeyType::Shift, (symMode || urlMode) ? shiftLabel(0) : shiftLabel(shiftState)}, + {(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}, diff --git a/src/activities/util/KeyboardEntryActivity.h b/src/activities/util/KeyboardEntryActivity.h index f135429d..c6b659cb 100644 --- a/src/activities/util/KeyboardEntryActivity.h +++ b/src/activities/util/KeyboardEntryActivity.h @@ -14,7 +14,7 @@ struct KeyDef { char secondary; }; -enum SpecialKeyType { SpecShift, SpecMode, SpecSpace, SpecDel, SpecOk }; +enum class SpecialKeyType { Shift, Mode, Space, Del, Ok }; enum class InputType { Text, Password, Url }; @@ -51,18 +51,26 @@ class KeyboardEntryActivity : public Activity { 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 const char* shiftLabel(int shiftState); 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; + void onComplete(std::string text); void onCancel(); @@ -75,8 +83,7 @@ class KeyboardEntryActivity : public Activity { static constexpr int BOTTOM_KEY_COUNT = 5; static constexpr KeyDef abcLayout[ABC_ROWS][COLS] = { - {{'0', ')'}, - {'1', '!'}, + {{'1', '!'}, {'2', '@'}, {'3', '#'}, {'4', '$'}, @@ -84,7 +91,8 @@ class KeyboardEntryActivity : public Activity { {'6', '^'}, {'7', '&'}, {'8', '*'}, - {'9', '('}}, + {'9', '('}, + {'0', ')'}}, {{'q', 'Q'}, {'w', 'W'}, {'e', 'E'}, @@ -118,8 +126,7 @@ class KeyboardEntryActivity : public Activity { }; static constexpr KeyDef symLayout[SYM_ROWS][COLS] = { - {{'0', '\0'}, - {'1', '\0'}, + {{'1', '\0'}, {'2', '\0'}, {'3', '\0'}, {'4', '\0'}, @@ -127,9 +134,9 @@ class KeyboardEntryActivity : public Activity { {'6', '\0'}, {'7', '\0'}, {'8', '\0'}, - {'9', '\0'}}, - {{')', '\0'}, - {'!', '\0'}, + {'9', '\0'}, + {'0', '\0'}}, + {{'!', '\0'}, {'@', '\0'}, {'#', '\0'}, {'$', '\0'}, @@ -137,7 +144,8 @@ class KeyboardEntryActivity : public Activity { {'^', '\0'}, {'&', '\0'}, {'*', '\0'}, - {'(', '\0'}}, + {'(', '\0'}, + {')', '\0'}}, {{'-', '\0'}, {'_', '\0'}, {'=', '\0'},