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:
@@ -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
|
||||
Reference in New Issue
Block a user