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
+88
View File
@@ -47,6 +47,7 @@
#include "ui/explorer/explorer_block_cache.h"
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_lerp.h"
#include "ui/windows/chat_thread_layout.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
#include "ui/windows/console_model.h"
@@ -14427,6 +14428,92 @@ void testLocalTime()
dragonx::util::refreshTimezone();
#endif
}
void testChatThreadLayout()
{
using namespace dragonx::ui::chatthread;
// computeFlags: reference scenarios
{
std::vector<MsgIn> in = {{1000,false},{1100,false},{1500,false},{1600,true},{1700,true},{90000,true}};
std::vector<std::int64_t> dk = {20260101,20260101,20260101,20260101,20260101,20260102};
auto f = computeFlags(in, dk, 300);
EXPECT_TRUE(f[0].newDay && f[0].startGroup && !f[0].lastInGroup); // first
EXPECT_TRUE(!f[1].newDay && !f[1].startGroup && f[1].lastInGroup); // 1500-1100=400>300 -> run ends
EXPECT_TRUE(f[2].startGroup && f[2].lastInGroup); // gap 400 -> new group; next flips direction
EXPECT_TRUE(f[3].startGroup && !f[3].lastInGroup); // direction flip
EXPECT_TRUE(!f[4].startGroup && f[4].lastInGroup); // next is a new day
EXPECT_TRUE(f[5].newDay && f[5].startGroup && f[5].lastInGroup);
}
EXPECT_TRUE(computeFlags({}, {}, 300).empty());
{ // dayKey == -1 on the first message (localtime failure): old code compared against prevDay=-1 -> not a new day
auto f = computeFlags({{5,false}}, {-1}, 300);
EXPECT_TRUE(!f[0].newDay && f[0].startGroup);
}
// snapPx / rowHeight: parts truncated individually, order-independent of float sums
{
EXPECT_NEAR(snapPx(7.9f), 7.0f, 1e-6);
HeightParams p; p.groupGap = 7.5f; p.msgGap = 3.5f; p.dayExtraGap = 4.5f; p.dayPillH = 20.7f;
p.metaLineH = 15.4f; p.statusH = 12.6f; p.failedPad = 1.5f; p.failedRowH = 18.2f;
MsgFlags first; first.newDay = first.startGroup = true; first.lastInGroup = true;
// first message: no leading gaps; pill + meta + bubble + status + msgGap
EXPECT_NEAR(rowHeight(p, true, first, true, false, false, 40.9f), 20 + 15 + 40 + 12 + 3, 1e-4);
MsgFlags mid; mid.startGroup = true; mid.lastInGroup = false;
EXPECT_NEAR(rowHeight(p, false, mid, false, false, false, 30.0f), 7 + 15 + 30 + 1 /*msgGap*0.5=1.75->1*/, 1e-4);
// failed: no status glyph, failed pad + row instead
MsgFlags l; l.lastInGroup = true;
EXPECT_NEAR(rowHeight(p, false, l, true, true, false, 30.0f), 30 + 1 + 18 + 3, 1e-4);
// sending (not last in group) still shows the clock glyph
MsgFlags s; s.lastInGroup = false;
EXPECT_NEAR(rowHeight(p, false, s, true, false, true, 30.0f), 30 + 12 + 1, 1e-4);
}
// prefix + visibleRange
{
std::vector<float> h = {10, 20, 30, 40};
auto pf = prefixOffsets(h);
EXPECT_EQ(pf.size(), static_cast<std::size_t>(5));
EXPECT_NEAR(pf[4], 100.0f, 1e-6);
std::size_t a = 9, b = 9;
visibleRange(pf, 0, 10, a, b); EXPECT_EQ(a, (std::size_t)0); EXPECT_EQ(b, (std::size_t)1); // (0,10) -> msg0 only
visibleRange(pf, 10, 30, a, b); EXPECT_EQ(a, (std::size_t)1); EXPECT_EQ(b, (std::size_t)2); // msg0 bottom==10 excluded
visibleRange(pf, 25, 65, a, b); EXPECT_EQ(a, (std::size_t)1); EXPECT_EQ(b, (std::size_t)4);
visibleRange(pf, -50, 1000, a, b); EXPECT_EQ(a, (std::size_t)0); EXPECT_EQ(b, (std::size_t)4);
visibleRange(pf, 500, 600, a, b); EXPECT_EQ(a, (std::size_t)4); EXPECT_EQ(b, (std::size_t)4); // past the end -> empty
visibleRange(std::vector<float>{0.0f}, 0, 10, a, b); EXPECT_EQ(b, (std::size_t)0); // no messages
}
// renderIndices: union, sorted, unique, out-of-range dropped
{
auto r = renderIndices(10, 4, 7, {1, 5, 9, 12});
EXPECT_TRUE((r == std::vector<std::size_t>{1, 4, 5, 6, 9}));
EXPECT_TRUE(renderIndices(0, 0, 0, {3}).empty());
}
{ // render set: strictly increasing, covers [a,b), keeps in-range forced, drops out-of-range; spacers >= 0
unsigned seed = 12345u;
auto rnd = [&]() { seed = seed * 1664525u + 1013904223u; return seed >> 8; };
for (int iter = 0; iter < 200; ++iter) {
const std::size_t n = 1 + rnd() % 60;
std::vector<float> h(n); for (auto& x : h) x = float(5 + rnd() % 90);
auto pf = prefixOffsets(h);
std::vector<std::size_t> forced; for (int k = 0; k < 3; ++k) forced.push_back(rnd() % (n + 2));
std::size_t a = 0, b = 0; visibleRange(pf, float(rnd() % 400), float(400 + rnd() % 400), a, b);
auto idx = renderIndices(n, a, b, forced);
for (std::size_t k = 1; k < idx.size(); ++k) EXPECT_TRUE(idx[k - 1] < idx[k]);
for (std::size_t i = a; i < b; ++i) EXPECT_TRUE(std::binary_search(idx.begin(), idx.end(), i));
for (std::size_t f : forced) EXPECT_EQ(std::binary_search(idx.begin(), idx.end(), f), f < n);
std::size_t next = 0; float y = 0.0f; // replay the render loop's cursor
for (std::size_t i : idx) { EXPECT_TRUE(pf[i] - pf[next] >= 0.0f); y += (pf[i] - pf[next]) + h[i];
EXPECT_NEAR(y, pf[i + 1], 1e-3); next = i + 1; }
}
}
{ // LayoutKey equality is sensitive to every field (fonts by FontId, not pointer)
LayoutKey k; k.innerW0 = 400; k.innerWn = 400.4f; k.bodySz = 18; k.metaSz = 12; k.dp = 1; k.icfSz = 14;
k.curFontSize = 15; k.nameDensity = 1; k.nameFontId = 3; k.metaFontId = 7; k.curFontId = 5;
LayoutKey k2 = k; EXPECT_TRUE(k == k2);
k2.innerWn = 400.0f; EXPECT_TRUE(!(k == k2));
k2 = k; k2.compact = true; EXPECT_TRUE(!(k == k2));
k2 = k; k2.nameFontId = 4; EXPECT_TRUE(!(k == k2)); // atlas rebuild at the same size => new FontId
k2 = k; k2.curFontSize = 16; EXPECT_TRUE(!(k == k2));
}
}
} // namespace
int main()
@@ -14666,6 +14753,7 @@ int main()
testImageDecodeJobs();
testGeneratedResourceBehavior();
testLocalTime();
testChatThreadLayout();
if (g_failures != 0) {
std::cerr << g_failures << " test assertion(s) failed\n";