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