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