feat(ui-new): a cursor mode that keeps the keyboard in view

The cursor marks the character it stands before instead of pushing the
text apart: underlined while typing, an inverted block while moving it.
The grid stays on screen with the special row turned into the key hints;
Up/Down move a word, Hold-Enter deletes before the cursor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-08 23:41:40 +02:00
co-authored by Claude Opus 5.5
parent e09a90b325
commit 9e24abcbeb
3 changed files with 100 additions and 46 deletions
+6 -3
View File
@@ -208,11 +208,14 @@ shows Latin instead (see `KB_ACCENT_VARIANTS` below).
Shift is one-shot by default (capitalises the next letter, including whichever Shift is one-shot by default (capitalises the next letter, including whichever
candidate a T9 multi-tap cycle settles on) or Hold-Enter to toggle caps-lock; candidate a T9 multi-tap cycle settles on) or Hold-Enter to toggle caps-lock;
Hold-Clear erases the whole field. **UP from the top letter row** enters Hold-Clear erases the whole field. **UP from the top letter row** enters
**cursor mode** (LEFT/RIGHT move the insertion point; UP/DOWN jump to **cursor mode** (LEFT/RIGHT move the insertion point a codepoint; UP/DOWN a
start/end, then — pressed again once already at that boundary — continue on word back / on, then — pressed again once already at that end — continue on
to the special row / letter grid, the same destinations the plain grid wrap to the special row / letter grid, the same destinations the plain grid wrap
used to reach directly) so edits/inserts can target any point in the typed used to reach directly) so edits/inserts can target any point in the typed
text, not just the end; Enter/Cancel exit immediately from anywhere. text, not just the end. The grid stays drawn, unselected, with the special
row replaced by the key hints; the cursor is an inverted cell instead of the
typing underline. Hold-Enter deletes before the cursor without leaving;
Enter/Cancel exit immediately from anywhere.
Hold-Enter on a Latin-page letter cell with accented variants instead opens Hold-Enter on a Latin-page letter cell with accented variants instead opens
the **accent popup**: one horizontal row of `KB_ACCENT_VARIANTS[group]` the **accent popup**: one horizontal row of `KB_ACCENT_VARIANTS[group]`
(a UTF-8 string per base letter, same shape as a T9 group string), LEFT/RIGHT (a UTF-8 string per base letter, same shape as a T9 group string), LEFT/RIGHT
+22 -1
View File
@@ -73,7 +73,28 @@ history). Pick a quick message or **Custom message** for the keyboard.
### The keyboard ### The keyboard
The on-screen keyboard is a letter grid (or a phone-style T9 keypad, Settings › The on-screen keyboard is a letter grid (or a phone-style T9 keypad, Settings ›
Keyboard › Layout). Up from the top row moves the cursor through the text. Keyboard › Layout).
#### Moving the cursor
While you type, a line under the text marks where the next letter goes. To
fix something earlier in the message, press **Up** from the top row of keys.
The cursor turns into a block on the letter it stands before, the keys stay
on screen without a selection, and the bottom row shows what the arrows do
now (`<> char ^v word`):
| Key | Does |
| --- | ---- |
| **Left / Right** | Moves one letter |
| **Up** | Back to the start of the word (the one before, if already there) |
| **Down** | On to the end of the word (the next one, if already there) |
| **Hold Enter** | Deletes the letter before the cursor |
| **Enter**, **Back** | Back to the keys; typing carries on at the cursor |
Up at the very start of the text goes to the bottom row of keys, and Down at
the very end goes back to the top row. So if you pressed Up by mistake, one
Down returns you where you were. The text never shifts around the cursor:
what you see is what will be sent.
Accented letters don't need a language setting: **hold Enter** on the base Accented letters don't need a language setting: **hold Enter** on the base
letter and pick from its variants, for example `a` → `á à â ä å ą…`, `z` → letter and pick from its variants, for example `a` → `á à â ä å ą…`, `z` →
@@ -532,6 +532,29 @@ struct KeyboardWidget {
else if (key == KEY_DOWN) { cursor_pos = len; } else if (key == KEY_DOWN) { cursor_pos = len; }
} }
// Word steps for cursor mode: back to the start of the word before the
// cursor, on to the end of the one after it. Spaces are ASCII, so every stop
// is a codepoint boundary.
int wordStartBefore(int p) const {
while (p > 0 && buf[p - 1] == ' ') p--;
while (p > 0 && buf[p - 1] != ' ') p--;
return p;
}
int wordEndAfter(int p) const {
while (p < len && buf[p] == ' ') p++;
while (p < len && buf[p] != ' ') p++;
return p;
}
void deleteBeforeCursor() {
t9_cell = -1;
if (cursor_pos <= 0) return;
int n = kbUtf8LastCharBytes(buf, cursor_pos);
memmove(buf + cursor_pos - n, buf + cursor_pos, len - cursor_pos);
len -= n; cursor_pos -= n;
buf[len] = '\0';
}
// PIN preview: what it's for, centred, and the digits below it spaced out // PIN preview: what it's for, centred, and the digits below it spaced out
// (filled soft squares when masked), with an underline on the next slot. // (filled soft squares when masked), with an underline on the next slot.
void renderPinPreview(DisplayDriver& display, int sep_y) { void renderPinPreview(DisplayDriver& display, int sep_y) {
@@ -647,16 +670,8 @@ struct KeyboardWidget {
// wrap boundary, where cursor_line has already moved to the next row. // wrap boundary, where cursor_line has already moved to the next row.
bool cursor_here = (first_line + pl == cursor_line); bool cursor_here = (first_line + pl == cursor_line);
int line_end = (len < pe) ? len : pe; int line_end = (len < pe) ? len : pe;
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL char linebuf[KB_PREVIEW_BYTES + 2];
if (cursor_here) { if (len > ps) {
// Cursor drawn as an inserted '_' between whatever text precedes and
// follows it on this line -- reduces to the old "text + trailing _"
// when cursor_pos == len (after_n is always 0 in that case).
int before_n = cursor_pos - ps; if (before_n < 0) before_n = 0;
if (before_n > line_end - ps) before_n = line_end - ps;
int after_n = line_end - cursor_pos; if (after_n < 0) after_n = 0;
snprintf(linebuf, sizeof(linebuf), "%.*s_%.*s", before_n, buf + ps, after_n, buf + ps + before_n);
} else if (len > ps) {
snprintf(linebuf, sizeof(linebuf), "%.*s", line_end - ps, buf + ps); snprintf(linebuf, sizeof(linebuf), "%.*s", line_end - ps, buf + ps);
} else { } else {
linebuf[0] = '\0'; linebuf[0] = '\0';
@@ -667,9 +682,8 @@ struct KeyboardWidget {
int mi = 0; int mi = 0;
int blen = (int)strlen(linebuf); int blen = (int)strlen(linebuf);
for (int bi = 0; bi < blen; ) { for (int bi = 0; bi < blen; ) {
int u = kbUtf8CharBytesAt(linebuf, bi, blen); masked[mi++] = '*';
masked[mi++] = (u == 1 && linebuf[bi] == '_') ? '_' : '*'; bi += kbUtf8CharBytesAt(linebuf, bi, blen);
bi += u;
} }
masked[mi] = '\0'; masked[mi] = '\0';
strncpy(linebuf, masked, sizeof(linebuf)); strncpy(linebuf, masked, sizeof(linebuf));
@@ -678,6 +692,27 @@ struct KeyboardWidget {
display.translateUTF8ToBlocks(linebuf_t, linebuf, sizeof(linebuf_t)); display.translateUTF8ToBlocks(linebuf_t, linebuf, sizeof(linebuf_t));
display.setCursor(0, pl * lh); display.setCursor(0, pl * lh);
display.print(linebuf_t); display.print(linebuf_t);
// The cursor marks the character it stands before, so the text never
// shifts around it: underlined while typing, inverted while moving it.
if (cursor_here) {
int at = 0;
for (int p = ps; p < cursor_pos; ) { p += kbUtf8CharBytesAt(buf, p, len); at++; }
const int bx = at * cw, by = pl * lh;
if (cursor_mode) {
display.fillRect(bx, by, cw, lh);
if (cursor_pos < len && !pin_kb_mask_enabled) {
char one[5], one_t[8];
kbUtf8CharAt(buf + cursor_pos, 0, one);
display.translateUTF8ToBlocks(one_t, one, sizeof(one_t));
display.setColor(DisplayDriver::DARK);
display.setCursor(bx, by);
display.print(one_t);
display.setColor(DisplayDriver::LIGHT);
}
} else {
display.fillRect(bx, by + lh - display.sepH(), cw - display.sepH(), display.sepH());
}
}
ps = pe; ps = pe;
} }
if (count[0]) { if (count[0]) {
@@ -713,22 +748,6 @@ struct KeyboardWidget {
} }
display.fillRect(0, sep_y, display.width(), display.sepH()); display.fillRect(0, sep_y, display.width(), display.sepH());
// Cursor-positioning mode: LEFT/RIGHT/UP/DOWN now drive the text cursor
// instead of the grid (see handleInput), so the grid would otherwise just
// sit there frozen with no sign anything's different. Replace it with an
// explicit hint instead.
if (cursor_mode) {
const int hh = lh + 2;
display.setColor(DisplayDriver::LIGHT);
display.fillRect(0, chars_y, display.width(), hh);
display.setColor(DisplayDriver::DARK);
display.drawTextCentered(display.width() / 2, chars_y + 1, "Cursor mode");
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, chars_y + hh + 2, "L/R move");
display.drawTextCentered(display.width() / 2, chars_y + hh + 2 + lh, "U/D start/end");
return 50;
}
// PIN mode // PIN mode
if (isPin()) { if (isPin()) {
const int s = miniIconScale(display); const int s = miniIconScale(display);
@@ -778,15 +797,19 @@ struct KeyboardWidget {
// just won't be visible on this screen which cell is selected. // just won't be visible on this screen which cell is selected.
if (compact_ui) { if (compact_ui) {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
if (cursor_mode) {
display.drawTextCentered(display.width() / 2, chars_y, "<> char ^v word");
} else {
display.drawTextCentered(display.width() / 2, chars_y, "Tab: placeholders"); display.drawTextCentered(display.width() / 2, chars_y, "Tab: placeholders");
display.drawTextCentered(display.width() / 2, chars_y + lh, "Fn+letter: accent"); display.drawTextCentered(display.width() / 2, chars_y + lh, "Fn+letter: accent");
}
} else { } else {
// character grid // character grid
if (isT9()) { if (isT9()) {
for (int r = 0; r < rows; r++) { for (int r = 0; r < rows; r++) {
int y = chars_y + r * cell_h; int y = chars_y + r * cell_h;
for (int c = 0; c < cols; c++) { for (int c = 0; c < cols; c++) {
bool sel = (row == r && col == c); bool sel = !cursor_mode && row == r && col == c;
int cell = r * cols + c; int cell = r * cols + c;
// Label the cell "<digit><group>" so it reads like a phone keypad. The // Label the cell "<digit><group>" so it reads like a phone keypad. The
// digit is what the multi-tap cycle lands on after the letters (see // digit is what the multi-tap cycle lands on after the letters (see
@@ -808,7 +831,7 @@ struct KeyboardWidget {
for (int r = 0; r < rows; r++) { for (int r = 0; r < rows; r++) {
int y = chars_y + r * cell_h; int y = chars_y + r * cell_h;
for (int c = 0; c < cols; c++) { for (int c = 0; c < cols; c++) {
bool sel = (row == r && col == c); bool sel = !cursor_mode && row == r && col == c;
char ch_buf[3]; char ch_buf[3];
kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf)); kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf));
if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_'; if (ch_buf[0] == ' ' && ch_buf[1] == '\0') ch_buf[0] = '_';
@@ -821,6 +844,12 @@ struct KeyboardWidget {
} }
} }
// Cursor mode: the arrows drive the text cursor (see handleKey), so the
// grid stays as it was, unselected, and the special row gives way to
// what the keys do now.
if (cursor_mode) {
display.drawTextCentered(display.width() / 2, spec_y + key_ty, "<> char ^v word");
} else {
// special row: caps ⇧ · space ⎵ · delete ⌫ · placeholders {} (text) · OK ✓ // special row: caps ⇧ · space ⎵ · delete ⌫ · placeholders {} (text) · OK ✓
const int s = miniIconScale(display); const int s = miniIconScale(display);
for (int i = 0; i < KB_SPECIAL; i++) { for (int i = 0; i < KB_SPECIAL; i++) {
@@ -864,6 +893,7 @@ struct KeyboardWidget {
display.setColor(DisplayDriver::LIGHT); display.setColor(DisplayDriver::LIGHT);
} }
} }
}
// Accent popup: floats over the still-visible grid (same idea as the // Accent popup: floats over the still-visible grid (same idea as the
// placeholder overlay just below), anchored on the held letter's own row // placeholder overlay just below), anchored on the held letter's own row
@@ -985,29 +1015,29 @@ struct KeyboardWidget {
// Cursor-positioning sub-mode (see the KEY_UP block below): the grid // Cursor-positioning sub-mode (see the KEY_UP block below): the grid
// selection is parked while LEFT/RIGHT walk cursor_pos one codepoint at a // selection is parked while LEFT/RIGHT walk cursor_pos one codepoint at a
// time. UP/DOWN jump to the very start/end -- Home/End, in effect -- and, // time and UP/DOWN a word back / on. Once already at that end, UP and
// once already at that boundary, continue the wrap the entry trigger // DOWN continue the wrap the entry trigger interrupted: UP again lands on
// interrupted: UP again lands on the special row, DOWN again back on the // the special row, DOWN again back on the letter grid's row 0 (so DOWN
// letter grid's row 0, same destinations the plain grid wrap used to reach // right after entering, the cursor still at the end, is the way back).
// directly (see the entry/exit comment below). Enter/Cancel just leave the // Hold-Enter deletes before the cursor without leaving; Enter/Cancel
// mode from anywhere; the actual edit (insert/backspace) happens back in // leave the mode from anywhere, typing resuming at the cursor.
// normal typing, now targeting the repositioned cursor.
if (cursor_mode) { if (cursor_mode) {
if (c == KEY_LEFT) { if (cursor_pos > 0) cursor_pos -= kbUtf8LastCharBytes(buf, cursor_pos); return NONE; } if (c == KEY_LEFT) { if (cursor_pos > 0) cursor_pos -= kbUtf8LastCharBytes(buf, cursor_pos); return NONE; }
if (c == KEY_RIGHT) { if (cursor_pos < len) cursor_pos += kbUtf8CharBytesAt(buf, cursor_pos, len); return NONE; } if (c == KEY_RIGHT) { if (cursor_pos < len) cursor_pos += kbUtf8CharBytesAt(buf, cursor_pos, len); return NONE; }
if (c == KEY_UP) { if (c == KEY_UP) {
if (cursor_pos > 0) { cursor_pos = 0; return NONE; } if (cursor_pos > 0) { cursor_pos = wordStartBefore(cursor_pos); return NONE; }
cursor_mode = false; cursor_mode = false;
row = gridRows(); row = gridRows();
col = col * KB_SPECIAL / gridCols(); col = col * KB_SPECIAL / gridCols();
return NONE; return NONE;
} }
if (c == KEY_DOWN) { if (c == KEY_DOWN) {
if (cursor_pos < len) { cursor_pos = len; return NONE; } if (cursor_pos < len) { cursor_pos = wordEndAfter(cursor_pos); return NONE; }
cursor_mode = false; cursor_mode = false;
row = 0; row = 0;
return NONE; return NONE;
} }
if (c == KEY_CONTEXT_MENU || c == 0x08) { deleteBeforeCursor(); return NONE; }
// KEY_KB_ENTER (external keyboard's Fn+Enter) leaves the mode too rather // KEY_KB_ENTER (external keyboard's Fn+Enter) leaves the mode too rather
// than being silently eaten -- it's the one key an external-keyboard // than being silently eaten -- it's the one key an external-keyboard
// typist would reach for here, and cursor mode is only ever entered from // typist would reach for here, and cursor mode is only ever entered from