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
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
**cursor mode** (LEFT/RIGHT move the insertion point; UP/DOWN jump to
start/end, then — pressed again once already at that boundary — continue on
**cursor mode** (LEFT/RIGHT move the insertion point a codepoint; UP/DOWN a
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
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
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
+22 -1
View File
@@ -73,7 +73,28 @@ history). Pick a quick message or **Custom message** for the keyboard.
### The keyboard
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
letter and pick from its variants, for example `a` → `á à â ä å ą…`, `z` →
@@ -532,6 +532,29 @@ struct KeyboardWidget {
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
// (filled soft squares when masked), with an underline on the next slot.
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.
bool cursor_here = (first_line + pl == cursor_line);
int line_end = (len < pe) ? len : pe;
char linebuf[KB_PREVIEW_BYTES + 2]; // cpl codepoints + cursor '_' + NUL
if (cursor_here) {
// 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) {
char linebuf[KB_PREVIEW_BYTES + 2];
if (len > ps) {
snprintf(linebuf, sizeof(linebuf), "%.*s", line_end - ps, buf + ps);
} else {
linebuf[0] = '\0';
@@ -667,9 +682,8 @@ struct KeyboardWidget {
int mi = 0;
int blen = (int)strlen(linebuf);
for (int bi = 0; bi < blen; ) {
int u = kbUtf8CharBytesAt(linebuf, bi, blen);
masked[mi++] = (u == 1 && linebuf[bi] == '_') ? '_' : '*';
bi += u;
masked[mi++] = '*';
bi += kbUtf8CharBytesAt(linebuf, bi, blen);
}
masked[mi] = '\0';
strncpy(linebuf, masked, sizeof(linebuf));
@@ -678,6 +692,27 @@ struct KeyboardWidget {
display.translateUTF8ToBlocks(linebuf_t, linebuf, sizeof(linebuf_t));
display.setCursor(0, pl * lh);
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;
}
if (count[0]) {
@@ -713,22 +748,6 @@ struct KeyboardWidget {
}
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
if (isPin()) {
const int s = miniIconScale(display);
@@ -778,15 +797,19 @@ struct KeyboardWidget {
// just won't be visible on this screen which cell is selected.
if (compact_ui) {
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(display.width() / 2, chars_y, "Tab: placeholders");
display.drawTextCentered(display.width() / 2, chars_y + lh, "Fn+letter: accent");
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 + lh, "Fn+letter: accent");
}
} else {
// character grid
if (isT9()) {
for (int r = 0; r < rows; r++) {
int y = chars_y + r * cell_h;
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;
// 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
@@ -808,7 +831,7 @@ struct KeyboardWidget {
for (int r = 0; r < rows; r++) {
int y = chars_y + r * cell_h;
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];
kbApplyCapsUtf8(cellStr(r, c), caps, ch_buf, sizeof(ch_buf));
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 ✓
const int s = miniIconScale(display);
for (int i = 0; i < KB_SPECIAL; i++) {
@@ -863,6 +892,7 @@ struct KeyboardWidget {
}
display.setColor(DisplayDriver::LIGHT);
}
}
}
// Accent popup: floats over the still-visible grid (same idea as the
@@ -985,29 +1015,29 @@ struct KeyboardWidget {
// 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
// time. UP/DOWN jump to the very start/end -- Home/End, in effect -- and,
// once already at that boundary, continue the wrap the entry trigger
// interrupted: UP again lands on the special row, DOWN again back on the
// letter grid's row 0, same destinations the plain grid wrap used to reach
// directly (see the entry/exit comment below). Enter/Cancel just leave the
// mode from anywhere; the actual edit (insert/backspace) happens back in
// normal typing, now targeting the repositioned cursor.
// time and UP/DOWN a word back / on. Once already at that end, UP and
// DOWN continue the wrap the entry trigger interrupted: UP again lands on
// the special row, DOWN again back on the letter grid's row 0 (so DOWN
// right after entering, the cursor still at the end, is the way back).
// Hold-Enter deletes before the cursor without leaving; Enter/Cancel
// leave the mode from anywhere, typing resuming at the cursor.
if (cursor_mode) {
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_UP) {
if (cursor_pos > 0) { cursor_pos = 0; return NONE; }
if (cursor_pos > 0) { cursor_pos = wordStartBefore(cursor_pos); return NONE; }
cursor_mode = false;
row = gridRows();
col = col * KB_SPECIAL / gridCols();
return NONE;
}
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;
row = 0;
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
// 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