refactor: redesign on-screen keyboard (#1644)

# Refactor: Redesign On-Screen Keyboard

## Summary

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.

## Screenshots

### Base Theme
|**master** | **PR #1644** |
|----------|-------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/49125857-12d0-4020-b872-05d0ddbf1d94"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/ad16656b-d66e-43dd-8697-2b85f709d7f8"
/> |

### Lyra Theme
| **master** | **PR #1644** |
|----------|-------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/d9901251-9154-48d2-83b4-376d3223d132"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/84b45949-ed61-4924-af1b-d570c4c61e13"
/> |

### Keyboard States

| ABC Mode | Symbol Mode | URL Mode |
|----------|-------------|----------|
| <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/6397a82e-50b8-4d03-92e0-f707ed7c9054"
/> | <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/205d34fe-0413-49e9-9db2-30569c297ba0"
/> | <img width="480" height="280" alt="image"
src="https://github.com/user-attachments/assets/801ddeab-e082-4a22-a9df-c66adb1afc16"
/> |

| Cursor Mode | Password Toggle |
|-------------|-----------------|
| <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/841773e1-7a3c-45e5-aa89-e5787255608c"
/> | <img width="480" height="800" alt="image"
src="https://github.com/user-attachments/assets/9487a0b3-3f47-41ed-9cd3-b24e56e342a8"
/> |

## Changes

### Layout (10-column uniform grid)
- 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 Symbol Mode (#@!)
- 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

### URL 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

### Cursor Mode
- **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

### Password Mode
- `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

### InputType Enum
- Replaced `bool isPassword` constructor parameter with `enum class
InputType { Text, Password, Url }`
- Callers updated: `WifiSelectionActivity`, `KOReaderSettingsActivity`,
`CalibreSettingsActivity`

### Contextual Tips
- `"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"`

### Hint Phases (cursor mode, Password only)
- **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"`

### Long-Press Alternative Character
- 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.

### Shift (2 sticky states)
- 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)

### SpecialKeyType Enum
- `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<int>()` 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

### Visual Improvements (both Base and Lyra themes)
- **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`

### New Theme Metrics
- `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)

### Defensive Improvements
- **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)
- **`<algorithm>` header**: included for `std::max`

## Files Modified

| 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` |

## Backward Compatibility

- **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

## Testing

### Input & Text Handling
- [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

### Mode Switching
- [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

### Shift Behavior
- [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

### Long-Press
- [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

### Cursor Mode
- [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)

### Password Mode
- [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

### URL Mode
- [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

### Re-entry State Reset
- [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

### Navigation
- [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

### Visual (both themes)
- [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

### Device & Theme Coverage
- [ ] 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

### Toggle Position
- [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

### Caret Visual in Toggle
- [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

### Contextual Tips
- [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

### Hint Phases
- [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

### Long-Press `InputType::Url` Behavior
- [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

### Number Row Reorder
- [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)

### Block Cursor Alignment
- [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

### Integration
- [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)
This commit is contained in:
pablohc
2026-04-18 16:12:56 -05:00
committed by GitHub
parent fedcb2f53d
commit 77b2c31635
10 changed files with 980 additions and 332 deletions
@@ -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<KeyboardEntryActivity>(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<KeyboardResult>(result.data).text;
// state will be updated in next loop iteration
}
});
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data).text;
// state will be updated in next loop iteration
}
});
} else {
// Connect directly for open networks
attemptConnection();
@@ -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<KeyboardEntryActivity>(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<KeyboardResult>(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<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
@@ -76,7 +80,7 @@ void CalibreSettingsActivity::handleSelection() {
} else if (selectedIndex == 2) {
// Password
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
@@ -54,9 +54,7 @@ void KOReaderSettingsActivity::handleSelection() {
if (selectedIndex == 0) {
// Username
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
@@ -66,33 +64,31 @@ void KOReaderSettingsActivity::handleSelection() {
});
} else if (selectedIndex == 1) {
// Password
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), kb.text);
KOREADER_STORE.saveToFile();
}
});
startActivityForResult(
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_KOREADER_PASSWORD),
KOREADER_STORE.getPassword(), 64, InputType::Password),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& kb = std::get<KeyboardResult>(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<KeyboardEntryActivity>(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<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SYNC_SERVER_URL),
prefillUrl, 128, InputType::Url),
[this](const ActivityResult& result) {
if (!result.isCancelled) {
const auto& kb = std::get<KeyboardResult>(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();
+639 -224
View File
@@ -1,192 +1,362 @@
#include "KeyboardEntryActivity.h"
#include <HalGPIO.h>
#include <I18n.h>
#include <algorithm>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
// Keyboard layouts - lowercase
const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
"`1234567890-=", "qwertyuiop[]\\", "asdfghjkl;'", "zxcvbnm,./",
"^ _____<OK" // ^ = shift, _ = space, < = backspace, OK = done
};
// Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "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<SpecialKeyType>(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<int>(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<int>(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<int>(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<int>(displayText.length()));
bool isCursorLine = false;
if (!cursorDrawn && cursorPos >= lineStartIdx &&
(isLastLine ? cursorPos <= lineEndIdx : cursorPos < lineEndIdx)) {
std::string beforeCursor;
if (isPassword && !passwordVisible && cursorMode) {
beforeCursor = std::string(cursorPos - lineStartIdx, '*');
} else {
beforeCursor = displayText.substr(lineStartIdx, cursorPos - lineStartIdx);
}
int beforeWidth = renderer.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<int>(cursorPos) + (cursorPos < text.length() ? 1 : 0);
const int afterEnd = lineEndIdx;
if (afterStart < afterEnd) {
const std::string part3 = displayText.substr(afterStart, afterEnd - afterStart);
renderer.drawText(UI_12_FONT_ID, cursorPixelX + cursorCharWidth, inputStartY + inputHeight, part3.c_str());
}
} else {
renderer.drawText(UI_12_FONT_ID, 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<int>(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();
+142 -34
View File
@@ -1,6 +1,7 @@
#pragma once
#include <GfxRenderer.h>
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
@@ -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;
};
+62 -10
View File
@@ -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);
}
+21 -6
View File
@@ -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)
@@ -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 {
+54 -7
View File
@@ -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);
}
}
+12 -4
View File
@@ -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; }
};