feat(ui): message history newest-at-bottom; keyboard one-shot Shift + hold-clear

Message history (DM + channel) now stacks bubbles upward from the compose
row instead of the header, so the newest message sits at the bottom like
a typical messenger, with older messages progressively above. UP/DOWN and
the scrollbar direction are swapped to match; the newest bubble now keeps
the same breathing-room gap against the compose row as it does against
another bubble, instead of touching it.

Keyboard: Shift is one-shot by default (capitalises just the next
letter, then reverts), with Hold-Enter on Shift toggling a persistent
caps-lock for the old sticky behavior. Hold-Enter on Backspace clears
the whole field in one action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-07-17 10:02:00 +02:00
parent 9d44921d34
commit af0cd2116e
2 changed files with 91 additions and 29 deletions

View File

@@ -319,6 +319,10 @@ struct KeyboardWidget {
int row, col;
int page; // see totalPages()/pageIsAltAlphabet()/pageIsSymbols() below
bool caps;
// Shift is one-shot by default (like a phone keyboard: capitalises just the
// next letter, then reverts) — Hold-Enter on Shift toggles caps_lock, which
// keeps it on for a whole run of capitals instead.
bool caps_lock = false;
char _ph_buf[KB_PH_MAX][KB_PH_LEN];
int _ph_count;
PopupMenu _ph_menu;
@@ -433,6 +437,7 @@ struct KeyboardWidget {
row = col = 0;
page = 0;
caps = false;
caps_lock = false;
t9_cell = -1;
t9_cycle = 0;
_ph_menu.active = false;
@@ -617,11 +622,30 @@ struct KeyboardWidget {
return NONE;
}
if (c == KEY_CANCEL || c == KEY_CONTEXT_MENU) return CANCELLED;
if (c == KEY_CANCEL) return CANCELLED;
const int rows = gridRows();
const int cols = gridCols();
// Hold-Enter is normally "cancel", but over the two keys where a long-press
// has its own well-known meaning on a phone keyboard, it does that instead:
// Shift -> toggle a persistent caps-lock (a plain tap is one-shot -- see the
// commit sites below); Backspace -> clear the whole field in one action
// instead of holding it down. Every other cell keeps hold-to-cancel.
if (c == KEY_CONTEXT_MENU) {
if (row == rows && col == 0) { // Shift
caps_lock = !caps_lock;
caps = caps_lock;
return NONE;
}
if (row == rows && col == 2) { // Backspace
len = 0; buf[0] = '\0';
t9_cell = -1;
return NONE;
}
return CANCELLED;
}
if (c == KEY_UP) {
if (row > 0) {
row--;
@@ -692,6 +716,7 @@ struct KeyboardWidget {
buf[len] = '\0';
t9_cell = cell;
t9_cycle = 0;
if (caps && !caps_lock) caps = false; // one-shot: only this first tap gets capitalised
}
}
t9_last_ms = millis();
@@ -703,11 +728,14 @@ struct KeyboardWidget {
memcpy(buf + len, shown, n);
len += n;
buf[len] = '\0';
if (caps && !caps_lock) caps = false; // one-shot: revert after the letter it capitalised
}
} else {
t9_cell = -1; // any special-row action finalizes a pending multi-tap cycle
switch (col) {
case 0: caps = !caps; break;
// Tap toggles one-shot caps on/off; while caps_lock is held (Hold-Enter
// on this key, see handleInput's top), a tap cancels the lock instead.
case 0: if (caps_lock) { caps = false; caps_lock = false; } else { caps = !caps; } break;
case 1:
if (len < max_len) { buf[len++] = ' '; buf[len] = '\0'; }
break;

View File

@@ -1024,8 +1024,14 @@ public:
});
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row, so item 0 (the
// newest message) lands at the bottom of the list and older messages
// sit progressively higher — same anchor convention as a typical
// messenger, instead of newest-at-top. box_ys[i] still corresponds
// to item (_dm_hist_scroll + i), same as before; only its y flips.
const int fixed_bh = 2 * lh + 1;
int cur_y = hist_start_y;
const int box_gap = portrait_expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_dm_hist_scroll + ii) < dm_count; ii++) {
int bh = fixed_bh;
if (portrait_expand) {
@@ -1038,9 +1044,10 @@ public:
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
}
}
if (cur_y + bh > cby) break;
box_ys[n_vis] = cur_y; box_hs[n_vis] = bh; n_vis++;
cur_y += bh + (portrait_expand ? 2 : 1);
int box_top = cur_y - bh;
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
}
}
_hist_visible = n_vis;
@@ -1085,9 +1092,16 @@ public:
// Scrollbar: track pinned to the full list area; thumb sized/positioned
// from hs's pixel metrics (stable while scrolling the same list).
if (hs.need)
// hs.scroll_px counts index-space distance from item 0 (newest); since
// item 0 now renders at the bottom, invert it here so the thumb sits at
// the bottom for the newest and rises as you scroll into older history.
if (hs.need) {
long span = hs.total_px - hs.view_px;
long inv_scroll_px = span - hs.scroll_px;
if (inv_scroll_px < 0) inv_scroll_px = 0;
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, hs.scroll_px);
hs.total_px, hs.view_px, inv_scroll_px);
}
drawComposeButton(display, cby, lh, _dm_hist_sel == -1);
if (_ctx_menu.active) _ctx_menu.render(display);
@@ -1153,8 +1167,12 @@ public:
});
int reserve = hs.reserve;
{
// Stack boxes upward from just above the compose row — see the DM
// history block above for why (newest at the bottom, like a typical
// messenger). box_ys[i] still corresponds to item (_hist_scroll + i).
const int fixed_bh = 2 * lh + 1;
int cur_y = hist_start_y;
const int box_gap = portrait_expand ? 2 : 1;
int cur_y = cby - box_gap; // reserve the same gap against compose as between boxes
for (int ii = 0; ii < MAX_VIS_BOXES && (_hist_scroll + ii) < ch_hist_count; ii++) {
int bh = fixed_bh;
if (portrait_expand) {
@@ -1168,9 +1186,10 @@ public:
bh = (1 + (nl > 0 ? nl : 1)) * lh + 1;
}
}
if (cur_y + bh > cby) break;
box_ys[n_vis] = cur_y; box_hs[n_vis] = bh; n_vis++;
cur_y += bh + (portrait_expand ? 2 : 1);
int box_top = cur_y - bh;
if (box_top < hist_start_y) break;
box_ys[n_vis] = box_top; box_hs[n_vis] = bh; n_vis++;
cur_y = box_top - box_gap;
}
}
_hist_visible = n_vis;
@@ -1228,9 +1247,16 @@ public:
}
// Scrollbar: track pinned to the full list area; thumb from hs metrics.
if (hs.need)
// Inverted for the same reason as the DM history block above — item 0
// (newest) renders at the bottom, so the thumb should start at the
// bottom too and rise as you scroll into older history.
if (hs.need) {
long span = hs.total_px - hs.view_px;
long inv_scroll_px = span - hs.scroll_px;
if (inv_scroll_px < 0) inv_scroll_px = 0;
drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px,
hs.total_px, hs.view_px, hs.scroll_px);
hs.total_px, hs.view_px, inv_scroll_px);
}
drawComposeButton(display, cby, lh, _hist_sel == -1);
if (_ctx_menu.active) _ctx_menu.render(display);
@@ -1700,16 +1726,12 @@ public:
}
return true;
}
// Newest (index 0) now renders at the bottom, oldest at the top (see the
// render block above), so UP/DOWN swap which direction walks the index:
// UP now climbs toward older (higher index, physically upward); DOWN
// now walks toward newer (lower index, physically downward) and off the
// bottom (index 0) reaches the compose row, which sits right below it.
if (c == KEY_UP) {
if (_dm_hist_sel > 0) {
_dm_hist_sel--;
if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel;
} else if (_dm_hist_sel == 0) {
_dm_hist_sel = -1;
}
return true;
}
if (c == KEY_DOWN) {
if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; }
else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) {
_dm_hist_sel++;
@@ -1718,6 +1740,15 @@ public:
}
return true;
}
if (c == KEY_DOWN) {
if (_dm_hist_sel > 0) {
_dm_hist_sel--;
if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel;
} else if (_dm_hist_sel == 0) {
_dm_hist_sel = -1;
}
return true;
}
if (c == KEY_ENTER) {
if (_dm_hist_sel >= 0) {
_dm_fs.begin();
@@ -1774,13 +1805,10 @@ public:
return true;
}
if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; }
// Newest (index 0) now renders at the bottom, oldest at the top (see the
// render block above) — UP/DOWN swap direction accordingly, same as the
// DM history handler above.
if (c == KEY_UP) {
if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; }
else if (_hist_sel == 0) _hist_sel = -1;
updateChannelUnread();
return true;
}
if (c == KEY_DOWN) {
if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; }
else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) {
_hist_sel++;
@@ -1789,6 +1817,12 @@ public:
updateChannelUnread();
return true;
}
if (c == KEY_DOWN) {
if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; }
else if (_hist_sel == 0) _hist_sel = -1;
updateChannelUnread();
return true;
}
if (c == KEY_ENTER) {
if (_hist_sel >= 0) {
_fs.begin();