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
+1
View File
@@ -664,6 +664,7 @@ set(APP_HEADERS
src/ui/windows/balance_address_list.h
src/ui/windows/balance_recent_tx.h
src/ui/windows/balance_lerp.h
src/ui/windows/chat_thread_layout.h
src/ui/windows/balance_tab_helpers.h
src/ui/windows/send_tab.h
src/ui/windows/receive_tab.h
+173 -21
View File
@@ -11,6 +11,8 @@
#include "../../chat/chat_service.h"
#include "../../util/i18n.h"
#include "../../util/local_time.h" // reentrant localtime (chat day keys / time labels)
#include "../../util/logger.h" // DEBUG_LOGF — warn-once layout-model checks (Tier 5 / C84)
#include "chat_thread_layout.h" // pure thread-view layout cache helpers (Tier 5 / C84)
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
#include "../../config/settings.h" // per-conversation mute (Q10)
@@ -22,6 +24,7 @@
#include "../layout.h" // Layout::dpiScale()
#include "../notifications.h" // Notifications — add-to-contacts confirmation
#include "imgui.h"
#include "imgui_internal.h" // GetCurrentWindow / IM_TRUNC: reproduce ItemSize's line-start x for the wrap cache
#include <sodium.h> // sodium_memzero — wipe typed plaintext on a wallet switch
@@ -34,6 +37,7 @@
#include <functional>
#include <ctime>
#include <string>
#include <unordered_map>
#include <vector>
namespace dragonx {
@@ -118,6 +122,16 @@ bool s_chat_time_12h = false;
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
// Height of the failed-send row ("not sent" text in metaFont + SmallButton, same line). SmallButton uses
// FramePadding.y == 0, so both are one text line tall; the row is the taller of the two. Mirrors the render code.
float chatFailedRowHeight(ImFont* metaFont) {
ImGui::PushFont(metaFont);
const float t = ImGui::CalcTextSize(TR("chat_send_failed")).y;
ImGui::PopFont();
const float b = ImGui::CalcTextSize(TR("chat_retry")).y;
return std::max(t, b);
}
// Case-insensitive substring match (ASCII) for the conversation search (Q8).
bool containsCI(const std::string& hay, const std::string& needle) {
if (needle.empty()) return true;
@@ -532,6 +546,18 @@ std::uint64_t s_convsKey = ~0ull;
std::string s_threadCid;
std::uint64_t s_threadRev = ~0ull;
std::vector<chat::ChatMessage> s_threadMsgs;
// Tier 5 (C84): per-thread layout cache so only messages near the viewport are laid out/drawn. Rebuilt only
// when the thread data, the layout key (wrap widths/fonts/sizes/dpi/density) or the minute bucket changes.
struct ChatThreadRow { float bodyW = 0, bodyH = 0, innerW = 0; bool hasDrgx = false; };
std::vector<chatthread::MsgFlags> s_threadFlags;
std::vector<ChatThreadRow> s_threadRows;
std::vector<float> s_threadHeights, s_threadPrefix = {0.0f};
std::vector<std::size_t> s_threadFailed; // always rendered (Retry / Tab nav)
std::unordered_map<std::string, chatthread::BodySize> s_threadBodySizes; // messageKey -> wrapped size (this thread only)
chatthread::LayoutKey s_threadKey;
bool s_threadRowsDirty = true;
std::int64_t s_threadFlagsMinute = -1;
int s_msgmenu_index = -1; // row whose ##msgmenu popup is open
// Centered, muted, wrapped hint for the empty states.
void centeredHint(const char* text) {
@@ -1330,10 +1356,12 @@ void RenderChatTab(App* app)
// MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and
// copies+sorts the match every call. Rebuild only when the open thread or the store changes,
// not every frame while the thread is simply being read/scrolled.
bool threadDataChanged = false;
if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) {
s_threadMsgs = store.conversation(s_selected_cid);
s_threadCid = s_selected_cid;
s_threadRev = store.revision();
threadDataChanged = true;
}
const auto& messages = s_threadMsgs;
// Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share
@@ -1346,28 +1374,125 @@ void RenderChatTab(App* app)
const float gutter = 2.0f * avR + 6.0f * dp; // incoming bubbles indent past the avatar
ImFont* icf = material::Type().iconSmall();
const float icfSz = scaledSize(icf);
std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1;
bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button?
const ImTextureID drgxTex = app->getDrgxEmojiTexture(); // ":drgx:" inline emoji
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
// ── Tier 5 (C84): thread layout cache — only messages near the viewport are laid out/drawn.
const float paraGap = bodySz * 0.35f;
const std::size_t nMsgs = messages.size();
// Wrap widths exactly as the old per-message code saw them. Message 0 is always this child's FIRST
// item, so it sees ImGui's raw (possibly fractional, 4*dp padding) start x; every later message
// starts at ItemSize's IM_TRUNC'd line start. Same float ops as ItemSize/GetContentRegionAvail
// (no table/columns in this child), so the cache wraps each body at the identical innerW.
const float availW0 = ImGui::GetContentRegionAvail().x;
float availWn = availW0;
{
ImGuiWindow* thrWin = ImGui::GetCurrentWindow();
const float lineX = IM_TRUNC(thrWin->Pos.x + thrWin->DC.Indent.x + thrWin->DC.ColumnsOffset.x);
availWn = thrWin->ContentRegionRect.Max.x - lineX;
}
auto innerWFor = [&](float aw) { return std::clamp(aw * 0.72f, 140.0f * dp, 560.0f * dp) - 2.0f * bpad; };
const float innerW0 = innerWFor(availW0), innerWn = innerWFor(availWn);
// (1) Grouping flags: once per data revision, plus once a minute (system tz change).
const std::int64_t minuteBucket = nowTs / 60;
if (threadDataChanged || minuteBucket != s_threadFlagsMinute) {
s_threadFlagsMinute = minuteBucket;
util::refreshTimezone();
std::vector<chatthread::MsgIn> in; std::vector<std::int64_t> dk;
in.reserve(nMsgs); dk.reserve(nMsgs);
for (const auto& mm : messages) {
in.push_back({mm.timestamp, mm.direction == chat::ChatDirection::Outgoing});
dk.push_back(chatDayKey(mm.timestamp));
}
auto fl = chatthread::computeFlags(in, dk, kGroupWindow);
if (threadDataChanged || fl != s_threadFlags) s_threadRowsDirty = true;
s_threadFlags = std::move(fl);
}
// (2) Layout key: everything that changes a wrapped body size or a Dummy height.
{
chatthread::LayoutKey lk;
lk.innerW0 = innerW0; lk.innerWn = innerWn; lk.bodySz = bodySz; lk.metaSz = metaSz;
lk.dp = dp; lk.icfSz = icfSz; lk.compact = compact;
lk.curFontSize = ImGui::GetFontSize(); lk.nameDensity = nameFont->CurrentRasterizerDensity;
lk.nameFontId = nameFont->FontId; lk.metaFontId = metaFont->FontId;
lk.curFontId = ImGui::GetFont()->FontId;
if (!(lk == s_threadKey)) { s_threadKey = lk; s_threadBodySizes.clear(); s_threadRowsDirty = true; }
}
// (3) Rows: body sizes (carried over by messageKey; only new/re-wrapped bodies are measured) + heights.
if (s_threadRowsDirty) {
s_threadRowsDirty = false;
const float gs = icfSz * 0.82f;
chatthread::HeightParams hpar;
hpar.groupGap = groupGap; hpar.msgGap = msgGap; hpar.dayExtraGap = 4.0f * dp;
hpar.dayPillH = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, "0").y + 2.0f * (2.0f * dp) + 4.0f * dp;
hpar.metaLineH = metaSz + 3.0f * dp;
hpar.statusH = gs + 2.0f * dp;
hpar.failedPad = 1.0f * dp;
hpar.failedRowH = chatFailedRowHeight(metaFont);
s_threadRows.assign(nMsgs, ChatThreadRow{});
s_threadHeights.assign(nMsgs, 0.0f);
s_threadFailed.clear();
// The rebuilt map holds ONLY this thread's keys: erased/cleared (lock) messages don't linger.
std::unordered_map<std::string, chatthread::BodySize> sizes;
sizes.reserve(nMsgs);
for (std::size_t i = 0; i < nMsgs; ++i) {
const auto& mm = messages[i];
const bool o = (mm.direction == chat::ChatDirection::Outgoing);
const bool fl = o && mm.delivery == chat::ChatDelivery::Failed;
const bool sn = o && mm.delivery == chat::ChatDelivery::Sending;
const bool drgx = mm.body.find(kDrgxToken) != std::string::npos;
const float iw = (i == 0) ? innerW0 : innerWn;
std::string bkey = chat::ChatStore::messageKey(mm.txid, mm.payload_position);
chatthread::BodySize bs;
const auto old = s_threadBodySizes.find(bkey);
if (old != s_threadBodySizes.end() && old->second.innerW == iw) {
bs = old->second;
} else {
const ImVec2 t = drgx
? layoutChatBodyRich(nullptr, nameFont, bodySz, mm.body, iw, paraGap, ImVec2(0, 0), 0, drgxTex)
: layoutChatBody(nullptr, nameFont, bodySz, mm.body, iw, paraGap, ImVec2(0, 0), 0);
bs.w = t.x; bs.h = t.y; bs.innerW = iw;
}
sizes[std::move(bkey)] = bs;
s_threadRows[i] = {bs.w, bs.h, iw, drgx};
s_threadHeights[i] = chatthread::rowHeight(hpar, i == 0, s_threadFlags[i], o, fl, sn,
bs.h + 2.0f * bpad);
if (fl) s_threadFailed.push_back(i);
}
s_threadBodySizes.swap(sizes);
s_threadPrefix = chatthread::prefixOffsets(s_threadHeights);
}
// (4) Rows to submit this frame: those within the viewport ± margin, plus forced ones.
const float contentTopY = ImGui::GetCursorScreenPos().y; // screen y of the first item (scroll applied)
const float cullMargin = 96.0f * dp;
std::size_t visFirst = 0, visEnd = 0;
chatthread::visibleRange(s_threadPrefix, msgWinMin.y - contentTopY - cullMargin,
msgWinMin.y + msgWinSize.y - contentTopY + cullMargin, visFirst, visEnd);
std::vector<std::size_t> forced = s_threadFailed; // Retry stays Tab-reachable
if (s_msgsel_dragging && s_msgsel_cid == s_selected_cid && s_msgsel_index >= 0)
forced.push_back(static_cast<std::size_t>(s_msgsel_index)); // drag past the edge keeps tracking
if (s_msgmenu_index >= 0) forced.push_back(static_cast<std::size_t>(s_msgmenu_index)); // popup keeps being submitted
const std::vector<std::size_t> renderIdx = chatthread::renderIndices(nMsgs, visFirst, visEnd, std::move(forced));
std::size_t nextIdx = 0; // first message not yet accounted for (rendered or spacer-skipped)
auto skipTo = [&](std::size_t upTo) { // one Dummy stands in for messages [nextIdx, upTo)
const float gap = s_threadPrefix[upTo] - s_threadPrefix[nextIdx];
if (gap >= 1.0f) ImGui::Dummy(ImVec2(availWn, gap));
};
for (const std::size_t mi : renderIdx) {
skipTo(mi);
const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi));
#ifdef DRAGONX_DEBUG
const float rowStartY = ImGui::GetCursorScreenPos().y;
#endif
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
const std::int64_t day = chatDayKey(m.timestamp);
const bool newDay = (day != prevDay);
const bool startGroup = newDay || (prevDir != static_cast<int>(outgoing)) ||
(prevTs < 0) || (m.timestamp - prevTs > kGroupWindow);
// Does this bubble end its run? (for grouped-corner shaping + the avatar placement)
bool lastInGroup = true;
if (mi + 1 < messages.size()) {
const auto& n = messages[mi + 1];
const bool nOut = (n.direction == chat::ChatDirection::Outgoing);
lastInGroup = (chatDayKey(n.timestamp) != day) || (nOut != outgoing) ||
(n.timestamp - m.timestamp > kGroupWindow);
}
const bool newDay = s_threadFlags[mi].newDay;
const bool startGroup = s_threadFlags[mi].startGroup;
const bool lastInGroup = s_threadFlags[mi].lastInGroup; // ends its run (grouped corners + avatar)
const float availW = ImGui::GetContentRegionAvail().x;
const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
@@ -1406,11 +1531,16 @@ void RenderChatTab(App* app)
// Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
// A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark);
// plain bodies keep the tighter text-only path so they're unaffected.
const float paraGap = bodySz * 0.35f;
const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
const ImVec2 tsz = hasDrgx
? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex)
: layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0);
// Body size from the thread cache (laid out once per data/width/font change, not twice per frame).
const bool hasDrgx = s_threadRows[mi].hasDrgx;
const ImVec2 tsz(s_threadRows[mi].bodyW, s_threadRows[mi].bodyH);
#ifdef DRAGONX_DEBUG
if (innerW != s_threadRows[mi].innerW) { // the cache must wrap at the live per-message width
static bool s_warnedW = false;
if (!s_warnedW) { s_warnedW = true;
DEBUG_LOGF("chat: cached wrap %.4f != live %.4f (msg %zu)\n", s_threadRows[mi].innerW, innerW, mi); }
}
#endif
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -1492,11 +1622,16 @@ void RenderChatTab(App* app)
const float hx = outgoing ? (bmin.x - hsz.x - 6.0f * dp) : (bmax.x + 6.0f * dp);
dl->AddText(metaFont, metaSz, ImVec2(hx, bmin.y + (bh - hsz.y) * 0.5f),
material::WithAlpha(material::OnSurface(), 120), ht.c_str());
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu");
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
ImGui::OpenPopup("##msgmenu");
s_msgmenu_index = static_cast<int>(mi); // keep submitting this message while its popup is open
}
}
if (ImGui::BeginPopup("##msgmenu")) {
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
ImGui::EndPopup();
} else if (s_msgmenu_index == static_cast<int>(mi)) {
s_msgmenu_index = -1; // popup closed
}
// Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an
@@ -1554,8 +1689,21 @@ void RenderChatTab(App* app)
}
ImGui::Dummy(ImVec2(0.0f, lastInGroup ? msgGap : msgGap * 0.5f)); // tight within a run
ImGui::PopID();
prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
#ifdef DRAGONX_DEBUG
// Height-model check. Exact only after the first item and at screen y >= 0: ItemSize's IM_TRUNC
// truncates toward zero, so rows above y=0 advance by ceil (imgui_internal.h IM_TRUNC). No self-heal.
if (mi > 0 && rowStartY >= 0.0f) {
const float measured = ImGui::GetCursorScreenPos().y - rowStartY;
static bool s_warnedH = false;
if (!s_warnedH && std::fabs(measured - s_threadHeights[mi]) > 0.5f) {
s_warnedH = true;
DEBUG_LOGF("chat: row %zu model %.1f != measured %.1f\n", mi, s_threadHeights[mi], measured);
}
}
#endif
nextIdx = mi + 1;
}
skipTo(nMsgs); // trailing spacer for the messages below the visible window
// A left-click inside the thread that hit no bubble or copy button clears the selection —
// except a click on the jump-to-latest pill, which is navigation, not a deselect.
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered()
@@ -2068,6 +2216,10 @@ void ResetChatTab()
s_threadCid.clear();
s_threadRev = ~0ull;
s_threadMsgs.clear();
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
s_threadKey = chatthread::LayoutKey{}; s_threadRowsDirty = true; s_threadFlagsMinute = -1;
s_msgmenu_index = -1;
}
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
+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
+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";