From 8006049e2a2f346523abe0d2b78a01b4424fc8dc Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:32:09 +0200 Subject: [PATCH] fix(ui): pin DM/channel history scrollbar track, stabilize thumb + gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portrait e-ink message history had two scrollbar glitches: - Track length tracked the last visible box's bottom, so a long message at the bottom that left empty space shrank the whole bar. Pin the track to the full list area (hist_start_y..cby); only the thumb sizes/moves. - Thumb size was derived from the per-frame visible-box count, which fluctuates with variable-height boxes. Drive it off pixel sums with a constant viewport (view_px = track_h) so the thumb stays stable while scrolling one list. - Gutter reserve used last frame's _hist_visible, so an incoming message briefly toggled the gutter and reflowed box widths. Decide it from a whole-list fit test at the widest layout instead — stable, no flicker. Unify both fixes in computeHistScroll() (shared by DM + channel views) and split drawScrollIndicator into a pixel core (drawScrollIndicatorPx) + an item-count wrapper. Co-Authored-By: Claude Opus 4.8 --- .../companion_radio/ui-new/QuickMsgScreen.h | 100 +++++++++++++++--- examples/companion_radio/ui-new/icons.h | 44 +++++--- 2 files changed, 115 insertions(+), 29 deletions(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index ed401b1e..ba663c56 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -150,6 +150,59 @@ class QuickMsgScreen : public UIScreen { &sensors, batt); } + // Scrollbar metrics for a history list. The track is pinned to the full list + // area (top_y..cby) so it never resizes with content; only the thumb sizes and + // moves. `need` also drives the gutter reserve so wide message boxes don't + // reflow as messages arrive. + struct HistScroll { bool need; int reserve; long total_px, scroll_px; int view_px; }; + + // `getBody(idx)` returns the body text for list item idx (the part that wraps), + // or nullptr to fall back to a fixed 2-line box. + template + HistScroll computeHistScroll(DisplayDriver& display, bool portrait, int count, int scroll, + int hist_start_y, int cby, int lh, GetBody getBody) { + HistScroll r{}; + const int fixed_bh = 2 * lh + 1; + const int top_y = hist_start_y + 1; + r.view_px = cby - top_y; // fixed track height = full list area + if (r.view_px < 1) r.view_px = 1; + const int col = scrollIndicatorColWidth(display); + + if (!portrait) { // uniform 2-line boxes → exact pixel math + const int box = fixed_bh + 1; + r.total_px = (long)count * box; + r.scroll_px = (long)scroll * box; + r.need = r.total_px > r.view_px; + r.reserve = r.need ? col : 0; + if (!r.need) r.scroll_px = 0; + return r; + } + + const int sp = 2; // portrait inter-box spacing + auto boxH = [&](int idx, int rsv) -> int { + const char* body = getBody(idx); + if (!body) return fixed_bh; + display.translateUTF8ToBlocks(s_wrap_trans, skipReplyPrefix(body), sizeof(s_wrap_trans)); + int nl = FullscreenMsgView::wrapLines(display, s_wrap_trans, display.width() - 6 - rsv, s_wrap_lines, 8); + return (1 + (nl > 0 ? nl : 1)) * lh + 1; + }; + // Scrollbar-needed test at the WIDEST layout (reserve 0 → fewest wrap lines → + // shortest total). If even this overflows the list area the gutter is truly + // needed, so it stays put and can't flicker in/out as messages arrive. + int cur = hist_start_y, fit = 0; + for (int i = 0; i < count; i++) { int bh = boxH(i, 0); if (cur + bh > cby) break; fit++; cur += bh + sp; } + r.need = fit < count; + r.reserve = r.need ? col : 0; + if (r.need) { // sum real box heights at the final width + for (int i = 0; i < count; i++) { + long ext = boxH(i, r.reserve) + sp; + r.total_px += ext; + if (i < scroll) r.scroll_px += ext; + } + } + return r; + } + // Strip "@[nick] " reply prefix from a message body for compact list display. static const char* skipReplyPrefix(const char* text) { if (text[0] == '@' && text[1] == '[') { @@ -952,10 +1005,16 @@ public: bool portrait_expand = (display.height() > display.width()); const int MAX_VIS_BOXES = 8; int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0; - // Scrollbar gutter, from last frame's visible count (this frame's isn't - // known until the layout loop runs); keeps portrait wrap width and the box - // width consistent so wrapped text never spills under the scrollbar. - int reserve = scrollIndicatorReserve(display, dm_count, _hist_visible); + // Fixed-track scrollbar metrics + a stable gutter reserve (decided by a + // whole-list fit test, not last frame's visible count) so message boxes + // don't reflow their width as messages arrive. + HistScroll hs = computeHistScroll(display, portrait_expand, dm_count, _dm_hist_scroll, + hist_start_y, cby, lh, + [&](int idx) -> const char* { + int rp = dmHistEntryForContact(_sel_contact.id.pub_key, idx); + return rp >= 0 ? _dm_hist[rp].text : nullptr; + }); + int reserve = hs.reserve; { const int fixed_bh = 2 * lh + 1; int cur_y = hist_start_y; @@ -1021,11 +1080,11 @@ public: display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); } - { - int arrow_y = (n_vis > 0) ? box_ys[n_vis - 1] + box_hs[n_vis - 1] - lh : hist_start_y; - drawScrollIndicator(display, hist_start_y + 1, arrow_y + lh - (hist_start_y + 1), - dm_count, _hist_visible, _dm_hist_scroll); - } + // 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) + drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, + hs.total_px, hs.view_px, hs.scroll_px); bool compose_sel = (_dm_hist_sel == -1); const char* ctxt = "[+ send]"; @@ -1090,8 +1149,17 @@ public: bool portrait_expand = (display.height() > display.width()); const int MAX_VIS_BOXES = 8; int box_ys[MAX_VIS_BOXES], box_hs[MAX_VIS_BOXES], n_vis = 0; - // Scrollbar gutter, from last frame's visible count (see DM history above). - int reserve = scrollIndicatorReserve(display, ch_hist_count, _hist_visible); + // Fixed-track scrollbar metrics + stable gutter reserve (see DM history above). + HistScroll hs = computeHistScroll(display, portrait_expand, ch_hist_count, _hist_scroll, + hist_start_y, cby, lh, + [&](int idx) -> const char* { + int rp = histEntryForChannel(_sel_channel_idx, idx); + if (rp < 0) return nullptr; + const char* t = _hist[rp].text; + const char* s = strstr(t, ": "); + return s ? s + 2 : t; + }); + int reserve = hs.reserve; { const int fixed_bh = 2 * lh + 1; int cur_y = hist_start_y; @@ -1175,12 +1243,10 @@ public: display.drawTextCentered(display.width()/2, display.height()/2, "No messages yet"); } - // scroll hints - { - int arrow_y = (n_vis > 0) ? box_ys[n_vis - 1] + box_hs[n_vis - 1] - lh : hist_start_y; - drawScrollIndicator(display, hist_start_y + 1, arrow_y + lh - (hist_start_y + 1), - ch_hist_count, _hist_visible, _hist_scroll); - } + // Scrollbar: track pinned to the full list area; thumb from hs metrics. + if (hs.need) + drawScrollIndicatorPx(display, hist_start_y + 1, hs.view_px, + hs.total_px, hs.view_px, hs.scroll_px); // small compose button (bottom-left, always bordered, inverted when selected) bool compose_sel = (_hist_sel == -1); diff --git a/examples/companion_radio/ui-new/icons.h b/examples/companion_radio/ui-new/icons.h index 6b58d986..89edbcf4 100644 --- a/examples/companion_radio/ui-new/icons.h +++ b/examples/companion_radio/ui-new/icons.h @@ -217,8 +217,11 @@ MINI_ICON(ICON_SCROLL_DOWN, 5, // ▼ // list fits and no indicator is drawn. Subtract from a row's content width so // text never runs under the scrollbar. Mirrors the column math below (5*scale // triangle + 1px gap). +inline int scrollIndicatorColWidth(DisplayDriver& d) { + return 5 * miniIconScale(d) + 1; // triangle (5*scale) + 1px gap +} inline int scrollIndicatorReserve(DisplayDriver& d, int total, int visible) { - return (total > visible) ? 5 * miniIconScale(d) + 1 : 0; + return (total > visible) ? scrollIndicatorColWidth(d) : 0; } // Right-edge scroll indicator: a proportional track + thumb topped/tailed with @@ -228,14 +231,22 @@ inline int scrollIndicatorReserve(DisplayDriver& d, int total, int visible) { // right edge and scales with the font (mini-icon scale). // top_y : y of the first visible row (top of the viewport) // track_h : pixel height of the viewport (e.g. visible_rows * row_pitch) -// total : total number of items -// visible : items shown at once -// first : index of the first visible item (scroll offset) -inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h, - int total, int visible, int first) { - if (total <= visible || track_h <= 0) return; // whole list fits — no indicator - if (first < 0) first = 0; - if (first > total - visible) first = total - visible; +// +// Core takes the proportions in arbitrary units. For uniform-height lists pass +// item counts (see the item wrapper below). For variable-height lists (e.g. the +// portrait DM history, where each message box wraps to a different height) pass +// pixel sums — total_px = Σ all box heights, view_px = pixels currently on +// screen, scroll_px = pixels above the first visible box — so the thumb size and +// position track real content extent instead of jumping as box counts fluctuate. +// total_px : total extent of the whole list (items or pixels) +// view_px : extent currently visible +// scroll_px : extent scrolled past (offset of the first visible item) +inline void drawScrollIndicatorPx(DisplayDriver& d, int top_y, int track_h, + long total_px, long view_px, long scroll_px) { + if (total_px <= view_px || track_h <= 0) return; // whole list fits — no indicator + const long span = total_px - view_px; // > 0 here + if (scroll_px < 0) scroll_px = 0; + if (scroll_px > span) scroll_px = span; const int s = miniIconScale(d); const int col = 5 * s; // triangle / column width @@ -254,11 +265,10 @@ inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h, const int bar_w = 3 * s; // odd width → centres exactly in the 5*s column const int bar_x = x + (col - bar_w) / 2; - int thumb_h = (int)((long)band * visible / total); + int thumb_h = (int)((long)band * view_px / total_px); if (thumb_h < th) thumb_h = th; if (thumb_h > band) thumb_h = band; - const int span = total - visible; // > 0 here (total > visible) - const int thumb_y = band_t + (int)((long)(band - thumb_h) * first / span); + const int thumb_y = band_t + (int)((long)(band - thumb_h) * scroll_px / span); // Each marker is drawn with a 1px DARK halo: invisible on a normal (dark) row, // but on the LIGHT selection bar it carves out contrast so the LIGHT marker @@ -276,6 +286,16 @@ inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h, miniIconDrawHalo(d, x, top_y + track_h - th, ICON_SCROLL_DOWN); } +// Uniform-height wrapper: one item = one unit. Drop-in replacement for +// DisplayDriver::drawScrollArrows. +// total : total number of items +// visible : items shown at once +// first : index of the first visible item (scroll offset) +inline void drawScrollIndicator(DisplayDriver& d, int top_y, int track_h, + int total, int visible, int first) { + drawScrollIndicatorPx(d, top_y, track_h, total, visible, first); +} + // Scrollable item-list skeleton shared by the list screens. Computes the visible // window from the font metrics, keeps `sel` in view, reserves the scroll-column, // draws each visible row through `row`, then the indicator. The callback