fix(ui): pin DM/channel history scrollbar track, stabilize thumb + gutter

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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-06-16 09:32:09 +02:00
parent f591cddf4f
commit 8006049e2a
2 changed files with 115 additions and 29 deletions

View File

@@ -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 <class GetBody>
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);

View File

@@ -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 fitsno 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 boxso 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