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