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
+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)