From af0cd2116e89b5c75cd66f88b17d3e581fdca2de Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:02:00 +0200 Subject: [PATCH] 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 --- .../companion_radio/ui-new/KeyboardWidget.h | 32 ++++++- .../companion_radio/ui-new/MessagesScreen.h | 88 +++++++++++++------ 2 files changed, 91 insertions(+), 29 deletions(-) diff --git a/examples/companion_radio/ui-new/KeyboardWidget.h b/examples/companion_radio/ui-new/KeyboardWidget.h index 9ff91a0a..34966112 100644 --- a/examples/companion_radio/ui-new/KeyboardWidget.h +++ b/examples/companion_radio/ui-new/KeyboardWidget.h @@ -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; diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index b1614be5..7b25883a 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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();