// 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