From 4a95d9ecb74c34c8d11821fa3b1326924ea146cc Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 10 Oct 2026 09:50:03 -0500 Subject: [PATCH] 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 --- CMakeLists.txt | 1 + src/ui/windows/chat_tab.cpp | 194 +++++++++++++++++++++++++--- src/ui/windows/chat_thread_layout.h | 131 +++++++++++++++++++ tests/test_phase4.cpp | 88 +++++++++++++ 4 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 src/ui/windows/chat_thread_layout.h diff --git a/CMakeLists.txt b/CMakeLists.txt index c10c755..eb2fd80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 1a87041..b04ae8b 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -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_memzero — wipe typed plaintext on a wallet switch @@ -34,6 +37,7 @@ #include #include #include +#include #include 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 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 s_threadFlags; +std::vector s_threadRows; +std::vector s_threadHeights, s_threadPrefix = {0.0f}; +std::vector s_threadFailed; // always rendered (Retry / Tab nav) +std::unordered_map 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 in; std::vector 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 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 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(s_msgsel_index)); // drag past the edge keeps tracking + if (s_msgmenu_index >= 0) forced.push_back(static_cast(s_msgmenu_index)); // popup keeps being submitted + const std::vector 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(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(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(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(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(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) diff --git a/src/ui/windows/chat_thread_layout.h b/src/ui/windows/chat_thread_layout.h new file mode 100644 index 0000000..52a5998 --- /dev/null +++ b/src/ui/windows/chat_thread_layout.h @@ -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 +#include +#include +#include + +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 computeFlags(const std::vector& in, + const std::vector& dayKeys, + std::int64_t groupWindow) { + std::vector 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(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(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(static_cast(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 prefixOffsets(const std::vector& heights) { + std::vector 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& 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::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::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 renderIndices(std::size_t n, std::size_t first, std::size_t end, + std::vector forced) { + std::vector 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index ce5f509..b1296f8 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 in = {{1000,false},{1100,false},{1500,false},{1600,true},{1700,true},{90000,true}}; + std::vector 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 h = {10, 20, 30, 40}; + auto pf = prefixOffsets(h); + EXPECT_EQ(pf.size(), static_cast(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{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{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 h(n); for (auto& x : h) x = float(5 + rnd() % 90); + auto pf = prefixOffsets(h); + std::vector 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";