perf(chat): lay out and draw only the visible chat thread rows

Every frame the open chat thread handled all N messages: grouping flags
from two day keys each, a measuring layout of every body, then the
draw, which laid every body out a second time and ran the bubble, hover
and popup code, however few rows were on screen.

The thread now keeps a layout cache: grouping flags, each body's
wrapped size (keyed by message, so only new or re-wrapped bodies are
measured) and each row's height, with prefix offsets. A frame binary-
searches the visible range and submits those rows plus any failed send
(Retry stays reachable with Tab), the row being drag-selected and the
row whose context menu is open; one spacer stands in for each skipped
run, and scrolling to the newest message works as before.

- Rows are rebuilt when the chat store changes or when the wrap width,
  fonts, font size, DPI scale, rasterizer density or compact mode
  change. Fonts are keyed by FontId, so a font reload at the same size
  (the colour-emoji toggle) re-measures. A rebuild is O(N), about
  0.5-1 ms at 3000 messages.
- Grouping flags are recomputed on a data change and once a minute,
  together with util::refreshTimezone(), so a system timezone change
  reaches chat timestamps within a minute.

Rows above the visible range are now one spacer of their summed
heights, so the visible rows no longer move by a pixel each time a row
above them crosses the top of the app window. Debug builds log once if
a cached wrap width or row height ever disagrees with the live layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:50:03 -05:00
co-authored by Claude Opus 5.5
parent a69380817e
commit 4a95d9ecb7
4 changed files with 393 additions and 21 deletions
+131
View File
@@ -0,0 +1,131 @@
// DragonX Wallet - chat thread view: pure layout-cache helpers (Tier 5 / C84).
// No ImGui dependency so the grouping rules, height model and virtualization range math are unit-tested.
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace dragonx::ui::chatthread {
struct MsgIn { std::int64_t timestamp = 0; bool outgoing = false; };
struct MsgFlags {
std::int64_t dayKey = -1;
bool newDay = false, startGroup = false, lastInGroup = true;
bool operator==(const MsgFlags& o) const {
return dayKey == o.dayKey && newDay == o.newDay && startGroup == o.startGroup && lastInGroup == o.lastInGroup;
}
};
// Exactly the rules the old per-frame loop used (chat_tab.cpp prevTs/prevDir/prevDay + lastInGroup):
// newDay = dayKey != previous message's dayKey (previous starts at -1)
// startGroup = newDay || direction flipped || first || gap > groupWindow
// lastInGroup= no next message, or next is a different day / direction / gap > groupWindow
inline std::vector<MsgFlags> computeFlags(const std::vector<MsgIn>& in,
const std::vector<std::int64_t>& dayKeys,
std::int64_t groupWindow) {
std::vector<MsgFlags> out(in.size());
std::int64_t prevTs = -1, prevDay = -1;
int prevDir = -1;
for (std::size_t i = 0; i < in.size(); ++i) {
MsgFlags& f = out[i];
f.dayKey = dayKeys[i];
f.newDay = (f.dayKey != prevDay);
f.startGroup = f.newDay || (prevDir != static_cast<int>(in[i].outgoing)) || (prevTs < 0) ||
(in[i].timestamp - prevTs > groupWindow);
f.lastInGroup = true;
if (i + 1 < in.size()) {
f.lastInGroup = (dayKeys[i + 1] != f.dayKey) || (in[i + 1].outgoing != in[i].outgoing) ||
(in[i + 1].timestamp - in[i].timestamp > groupWindow);
}
prevTs = in[i].timestamp; prevDir = static_cast<int>(in[i].outgoing); prevDay = f.dayKey;
}
return out;
}
// ImGui::ItemSize truncates the cursor to an integer pixel after every item (imgui.cpp IM_TRUNC), so a
// message's real height is the sum of its parts each truncated, not the float sum.
inline float snapPx(float h) { return static_cast<float>(static_cast<int>(h)); }
struct HeightParams {
float groupGap = 0, msgGap = 0;
float dayExtraGap = 0; // the "+ 4dp" above a date pill when it is not the first message
float dayPillH = 0; // pill text height + 2*pillPadY + 4dp (the Dummy under the pill)
float metaLineH = 0; // metaSz + 3dp
float statusH = 0; // status glyph Dummy: gs + 2dp
float failedPad = 0; // 1dp Dummy above the failed row
float failedRowH = 0; // height of the "not sent" + Retry row
};
// Vertical extent of message i = exactly the Dummy heights the render path emits, in the same order.
inline float rowHeight(const HeightParams& p, bool first, const MsgFlags& f, bool outgoing, bool failed,
bool sending, float bubbleH) {
float h = 0.0f;
if (f.newDay) {
if (!first) h += snapPx(p.groupGap + p.dayExtraGap);
h += snapPx(p.dayPillH);
}
if (f.startGroup) {
if (!first && !f.newDay) h += snapPx(p.groupGap);
h += snapPx(p.metaLineH);
}
h += snapPx(bubbleH);
if (outgoing && !failed && (sending || f.lastInGroup)) h += snapPx(p.statusH);
if (failed) h += snapPx(p.failedPad) + snapPx(p.failedRowH);
h += snapPx(f.lastInGroup ? p.msgGap : p.msgGap * 0.5f);
return h;
}
// prefix[i] = top offset of message i (relative to the first item); prefix[n] = total. Size n+1.
inline std::vector<float> prefixOffsets(const std::vector<float>& heights) {
std::vector<float> p(heights.size() + 1, 0.0f);
for (std::size_t i = 0; i < heights.size(); ++i) p[i + 1] = p[i] + heights[i];
return p;
}
// Messages intersecting the open interval (viewTop, viewBottom), as [first, end). prefix has n+1 entries.
inline void visibleRange(const std::vector<float>& prefix, float viewTop, float viewBottom,
std::size_t& first, std::size_t& end) {
const std::size_t n = prefix.empty() ? 0 : prefix.size() - 1;
if (n == 0) { first = end = 0; return; }
// first message whose bottom (prefix[i+1]) is below viewTop
first = static_cast<std::size_t>(std::upper_bound(prefix.begin() + 1, prefix.end(), viewTop) - (prefix.begin() + 1));
// first message whose top (prefix[i]) is at/after viewBottom
end = static_cast<std::size_t>(std::lower_bound(prefix.begin(), prefix.begin() + n, viewBottom) - prefix.begin());
if (first > n) first = n;
if (end < first) end = first;
}
// Sorted unique indices = [first,end) U forced (out-of-range forced entries dropped).
inline std::vector<std::size_t> renderIndices(std::size_t n, std::size_t first, std::size_t end,
std::vector<std::size_t> forced) {
std::vector<std::size_t> out;
out.reserve((end > first ? end - first : 0) + forced.size());
for (std::size_t i = first; i < end && i < n; ++i) out.push_back(i);
for (std::size_t f : forced) if (f < n) out.push_back(f);
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
return out;
}
// Everything that changes a body's wrapped size or a row's Dummy heights, except the data revision.
// Fonts are keyed by ImFont::FontId (unique for the atlas lifetime; a same-size rebuild such as the chat
// colour-emoji toggle gets NEW ids even if the ImFont* address is reused), not by pointer.
struct LayoutKey {
float innerW0 = -1, innerWn = -1; // wrap width of message 0 (first item) / of every later message
float bodySz = -1, metaSz = -1, dp = -1, icfSz = -1, curFontSize = -1, nameDensity = -1;
bool compact = false;
unsigned nameFontId = 0, metaFontId = 0, curFontId = 0;
bool operator==(const LayoutKey& o) const {
return innerW0 == o.innerW0 && innerWn == o.innerWn && bodySz == o.bodySz && metaSz == o.metaSz &&
dp == o.dp && icfSz == o.icfSz && curFontSize == o.curFontSize && nameDensity == o.nameDensity &&
compact == o.compact && nameFontId == o.nameFontId && metaFontId == o.metaFontId &&
curFontId == o.curFontId;
}
};
// Wrapped body size, with the wrap width it was measured at (index 0 and index >= 1 wrap differently).
struct BodySize { float w = 0, h = 0, innerW = -1; };
} // namespace dragonx::ui::chatthread