perf(console): wrap only new console lines via a per-line cache
Each batch of new output re-wrapped all N visible lines glyph by glyph, allocating a fresh segment vector per line; a line evicted at the 10,000-line cap shifted every index, so nothing could be kept. And on every frame, idle or not, seeding the scanline shading summed the wrap rows of every line above the view. - The model numbers its lines and the view keeps one wrap per line, aligned by that number, so an eviction drops a prefix instead of forcing a rebuild. New output wraps only the new lines; the layout is then a prefix sum over the N visible lines, with no measuring and no per-line allocation. - The wraps are kept while the wrap width, line height, font size, font (by ImFont::FontId) and rasterizer density are exactly unchanged. A line-spacing change only re-sums heights; filters and fold toggles reuse the cached wraps. A resize drag still re-wraps every visible line per frame, as before, but reuses their storage. - The scanline seed is read from the prefix sum: O(1) per frame. Heights, offsets and segments are bit-identical to the old rebuild. A font reload at an unchanged size (the colour-emoji toggle) now re-wraps the console at once; before, it waited for the next line of output. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5555,6 +5555,309 @@ void testConsoleTextLayout()
|
||||
EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
|
||||
}
|
||||
|
||||
// VERBATIM copy of the OLD full-rebuild builder and its result type (refs/scratch/t4g-c26
|
||||
// src/ui/windows/console_text_layout.{h,cpp}, before the wrap cache): the independent reference the incremental
|
||||
// ConsoleWrapCache + AssembleConsoleLayout path must reproduce bit for bit.
|
||||
namespace cwlegacy {
|
||||
using namespace dragonx::ui;
|
||||
|
||||
// The wrap layout for a set of visible lines.
|
||||
struct ConsoleLayout {
|
||||
std::vector<std::vector<ConsoleWrapSegment>> segments; // per visible line
|
||||
std::vector<float> heights; // per visible line (includes the inter-line gap)
|
||||
std::vector<float> cumulativeY; // cumulative Y offset of each visible line
|
||||
float totalHeight = 0.0f;
|
||||
};
|
||||
|
||||
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
|
||||
float wrapWidth, float lineHeight, float interLineGap,
|
||||
const ConsoleTextMeasure& measure)
|
||||
{
|
||||
ConsoleLayout L;
|
||||
if (visibleCount < 0) visibleCount = 0;
|
||||
L.segments.resize(visibleCount);
|
||||
L.heights.resize(visibleCount);
|
||||
L.cumulativeY.resize(visibleCount);
|
||||
L.totalHeight = 0.0f;
|
||||
|
||||
for (int vi = 0; vi < visibleCount; vi++) {
|
||||
const std::string& text = getVisibleText(vi);
|
||||
auto& segs = L.segments[vi];
|
||||
segs.clear();
|
||||
|
||||
if (text.empty()) {
|
||||
segs.push_back({0, 0, 0.0f, lineHeight});
|
||||
L.cumulativeY[vi] = L.totalHeight;
|
||||
L.heights[vi] = lineHeight + interLineGap;
|
||||
L.totalHeight += L.heights[vi];
|
||||
continue;
|
||||
}
|
||||
|
||||
const char* textStart = text.c_str();
|
||||
const char* textEnd = textStart + text.size();
|
||||
const char* cur = textStart;
|
||||
float segY = 0.0f;
|
||||
|
||||
while (cur < textEnd) {
|
||||
const char* wrapPos = measure.wrapPosition(cur, textEnd, wrapWidth);
|
||||
if (wrapPos <= cur) wrapPos = cur + 1; // ensure forward progress
|
||||
if (*cur == '\n') { cur++; continue; } // skip a newline ending the prior seg
|
||||
|
||||
int byteStart = static_cast<int>(cur - textStart);
|
||||
int byteEnd = static_cast<int>(wrapPos - textStart);
|
||||
if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; // trim trailing '\n'
|
||||
|
||||
segs.push_back({byteStart, byteEnd, segY, lineHeight});
|
||||
segY += lineHeight;
|
||||
cur = wrapPos;
|
||||
}
|
||||
|
||||
if (segs.empty()) {
|
||||
segs.push_back({0, 0, 0.0f, lineHeight});
|
||||
segY = lineHeight;
|
||||
}
|
||||
|
||||
L.cumulativeY[vi] = L.totalHeight;
|
||||
L.heights[vi] = segY + interLineGap;
|
||||
L.totalHeight += L.heights[vi];
|
||||
}
|
||||
|
||||
return L;
|
||||
}
|
||||
} // namespace cwlegacy
|
||||
|
||||
// Incremental console wrap cache (console_text_layout.h): per-raw-line wraps aligned to the model by absolute
|
||||
// sequence number; only new / invalidated lines are wrapped and the assembled layout is bit-identical to the
|
||||
// OLD full-rebuild builder (cwlegacy above) across appends, cap eviction, width and filter changes (C198).
|
||||
void testConsoleWrapCache()
|
||||
{
|
||||
using namespace dragonx::ui;
|
||||
struct CountingMeasure : ConsoleTextMeasure {
|
||||
float charW = 10.0f;
|
||||
mutable int calls = 0;
|
||||
float width(const char* b, const char* e) const override {
|
||||
return static_cast<float>(e - b) * charW;
|
||||
}
|
||||
const char* wrapPosition(const char* b, const char* e, float w) const override {
|
||||
++calls;
|
||||
int maxChars = static_cast<int>(w / charW);
|
||||
if (maxChars < 1) maxChars = 1;
|
||||
return (e - b) <= maxChars ? e : b + maxChars;
|
||||
}
|
||||
} m;
|
||||
auto sameLayout = [](const ConsoleLayout& a, const ConsoleLayout& b) {
|
||||
if (a.segments.size() != b.segments.size() || a.heights != b.heights ||
|
||||
a.cumulativeY != b.cumulativeY || a.rowStart != b.rowStart || a.totalHeight != b.totalHeight)
|
||||
return false;
|
||||
for (std::size_t i = 0; i < a.segments.size(); ++i) {
|
||||
if (a.segments[i].size() != b.segments[i].size()) return false;
|
||||
for (std::size_t s = 0; s < a.segments[i].size(); ++s) {
|
||||
const ConsoleWrapSegment& x = a.segments[i][s];
|
||||
const ConsoleWrapSegment& y = b.segments[i][s];
|
||||
if (x.byteStart != y.byteStart || x.byteEnd != y.byteEnd ||
|
||||
x.yOffset != y.yOffset || x.height != y.height) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
auto bitEq = [](float x, float y) { return std::memcmp(&x, &y, sizeof(float)) == 0; };
|
||||
auto sameAsLegacy = [&bitEq](const ConsoleLayout& a, const cwlegacy::ConsoleLayout& b) {
|
||||
const std::size_t V = b.segments.size();
|
||||
if (a.segments.size() != V || a.heights.size() != V || a.cumulativeY.size() != V ||
|
||||
b.heights.size() != V || b.cumulativeY.size() != V || a.rowStart.size() != V + 1 ||
|
||||
!bitEq(a.totalHeight, b.totalHeight))
|
||||
return false;
|
||||
int rows = 0;
|
||||
for (std::size_t i = 0; i < V; ++i) {
|
||||
if (a.rowStart[i] != rows || !bitEq(a.heights[i], b.heights[i]) ||
|
||||
!bitEq(a.cumulativeY[i], b.cumulativeY[i]) || a.segments[i].size() != b.segments[i].size())
|
||||
return false;
|
||||
rows += static_cast<int>(b.segments[i].size());
|
||||
for (std::size_t s = 0; s < b.segments[i].size(); ++s) {
|
||||
const ConsoleWrapSegment& x = a.segments[i][s];
|
||||
const ConsoleWrapSegment& y = b.segments[i][s];
|
||||
if (x.byteStart != y.byteStart || x.byteEnd != y.byteEnd ||
|
||||
!bitEq(x.yOffset, y.yOffset) || !bitEq(x.height, y.height)) return false;
|
||||
}
|
||||
}
|
||||
return a.rowStart[V] == rows;
|
||||
};
|
||||
auto lineText = [](int i) {
|
||||
std::string s;
|
||||
if (i % 5 == 0) return s; // empty line
|
||||
for (int k = 0; k < (i * 7) % 23; ++k) s += static_cast<char>('a' + (k + i) % 26);
|
||||
if (i % 7 == 0) s += "\nxyz"; // embedded newline
|
||||
if (i % 11 == 0) s += " \xC3\xA9\xE2\x82\xAC tail"; // multi-byte UTF-8
|
||||
return s;
|
||||
};
|
||||
std::deque<std::string> model;
|
||||
std::uint64_t firstSeq = 0;
|
||||
for (int i = 0; i < 200; ++i) model.push_back(lineText(i));
|
||||
auto getRaw = [&model](int i) -> const std::string& { return model[static_cast<std::size_t>(i)]; };
|
||||
auto all = [&model]() {
|
||||
std::vector<int> v;
|
||||
for (int i = 0; i < static_cast<int>(model.size()); ++i) v.push_back(i);
|
||||
return v;
|
||||
};
|
||||
const float H = 12.3f, G = 2.7f; // not dyadic: sums round, so a changed float op order would show
|
||||
auto fresh = [&](float w, float gap) {
|
||||
return BuildConsoleLayout(static_cast<int>(model.size()), getRaw, w, H, gap, m);
|
||||
};
|
||||
CountingMeasure refM; // the reference's own measure: never perturbs m.calls
|
||||
auto legacy = [&](const std::vector<int>& vis, float w, float gap) {
|
||||
auto getVis = [&](int vi) -> const std::string& {
|
||||
return model[static_cast<std::size_t>(vis[static_cast<std::size_t>(vi)])];
|
||||
};
|
||||
return cwlegacy::BuildConsoleLayout(static_cast<int>(vis.size()), getVis, w, H, gap, refM);
|
||||
};
|
||||
ConsoleWrapKey key;
|
||||
key.wrapWidth = 50.0f; key.lineHeight = H; key.fontSize = H; key.rasterDensity = 1.0f; key.fontId = 1;
|
||||
|
||||
ConsoleWrapCache cache;
|
||||
ConsoleLayout L;
|
||||
|
||||
// 1) Equivalence with the old builder (and the new one-shot one); each visible line wrapped exactly once;
|
||||
// rowStart = prefix rows.
|
||||
cache.sync(firstSeq, model.size(), key);
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m);
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G)));
|
||||
EXPECT_TRUE(sameAsLegacy(fresh(50.0f, G), legacy(all(), 50.0f, G)));
|
||||
EXPECT_TRUE(sameLayout(L, fresh(50.0f, G)));
|
||||
EXPECT_EQ(cache.wrapCount(), static_cast<std::size_t>(200));
|
||||
EXPECT_EQ(L.rowStart.size(), static_cast<std::size_t>(201));
|
||||
bool rowsOk = true;
|
||||
for (std::size_t i = 0; i < L.segments.size(); ++i)
|
||||
rowsOk = rowsOk && (L.rowStart[i + 1] - L.rowStart[i] == static_cast<int>(L.segments[i].size()));
|
||||
EXPECT_TRUE(rowsOk);
|
||||
|
||||
// 2) Append-only: only the 3 new lines are wrapped; existing entries do not move.
|
||||
const ConsoleWrapSegment* line1Segs = L.segments[1].begin();
|
||||
for (int i = 200; i < 203; ++i) model.push_back(lineText(i));
|
||||
std::size_t before = cache.wrapCount();
|
||||
cache.sync(firstSeq, model.size(), key);
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m);
|
||||
EXPECT_EQ(cache.wrapCount() - before, static_cast<std::size_t>(3));
|
||||
EXPECT_TRUE(L.segments[1].begin() == line1Segs);
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G)));
|
||||
|
||||
// 3) Cap eviction is an offset: 2 popped + 1 added => exactly 1 wrap; layout == fresh build of the survivors.
|
||||
model.pop_front(); model.pop_front(); firstSeq += 2;
|
||||
model.push_back(lineText(203));
|
||||
before = cache.wrapCount();
|
||||
cache.sync(firstSeq, model.size(), key);
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m);
|
||||
EXPECT_EQ(cache.wrapCount() - before, static_cast<std::size_t>(1));
|
||||
EXPECT_EQ(cache.size(), model.size());
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G)));
|
||||
|
||||
// 4) Gap-only change: zero wraps and zero measuring. Width change: every visible line re-wrapped.
|
||||
before = cache.wrapCount();
|
||||
const int callsBefore = m.calls;
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, 5.0f, m);
|
||||
EXPECT_EQ(cache.wrapCount() - before, static_cast<std::size_t>(0));
|
||||
EXPECT_EQ(m.calls, callsBefore);
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, 5.0f)));
|
||||
ConsoleWrapKey narrow = key;
|
||||
narrow.wrapWidth = 30.0f;
|
||||
EXPECT_FALSE(cache.matches(narrow));
|
||||
before = cache.wrapCount();
|
||||
cache.sync(firstSeq, model.size(), narrow);
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 30.0f, H, G, m);
|
||||
EXPECT_EQ(cache.wrapCount() - before, model.size());
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 30.0f, G)));
|
||||
EXPECT_TRUE(cache.matches(narrow));
|
||||
ConsoleWrapKey tiny = narrow; tiny.wrapWidth = 30.01f; // sub-1/16 px change still invalidates
|
||||
ConsoleWrapKey reloaded = narrow; reloaded.fontId = 2; // atlas reload at the same address/size
|
||||
EXPECT_FALSE(cache.matches(tiny));
|
||||
EXPECT_FALSE(cache.matches(reloaded));
|
||||
|
||||
// 5) Hit-testing the assembled layout == hit-testing the one-shot layout.
|
||||
{
|
||||
ConsoleLayout ref = fresh(30.0f, G);
|
||||
const float probes[][2] = {{0.f, 0.f}, {20.f, 45.f}, {-5.f, 100.f}, {35.f, 333.f}, {1000.f, 99999.f}};
|
||||
bool same = true;
|
||||
for (const auto& p : probes) {
|
||||
ConsoleHit a = HitTestConsoleLayout(L, static_cast<int>(model.size()), getRaw, p[0], p[1], m);
|
||||
ConsoleHit b = HitTestConsoleLayout(ref, static_cast<int>(model.size()), getRaw, p[0], p[1], m);
|
||||
same = same && a.visibleRow == b.visibleRow && a.col == b.col;
|
||||
}
|
||||
EXPECT_TRUE(same);
|
||||
}
|
||||
|
||||
// 5b) Filter changes reuse the cached wraps (zero wraps) and match the old builder over the filtered set; a
|
||||
// filtered view across a cap eviction + append wraps only the new line.
|
||||
{
|
||||
auto filtered = [&](bool (*pred)(const std::string&)) {
|
||||
std::vector<int> v;
|
||||
for (int i = 0; i < static_cast<int>(model.size()); ++i)
|
||||
if (pred(model[static_cast<std::size_t>(i)])) v.push_back(i);
|
||||
return v;
|
||||
};
|
||||
bool (*hasE)(const std::string&) = [](const std::string& s) { return s.find('e') != std::string::npos; };
|
||||
bool (*blankOrMulti)(const std::string&) = [](const std::string& s) {
|
||||
return s.empty() || s.find('\n') != std::string::npos;
|
||||
};
|
||||
before = cache.wrapCount();
|
||||
for (auto pred : {hasE, blankOrMulti, hasE}) {
|
||||
const std::vector<int> vis = filtered(pred);
|
||||
EXPECT_TRUE(!vis.empty() && vis.size() < model.size());
|
||||
AssembleConsoleLayout(L, cache, vis, getRaw, 30.0f, H, G, m);
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(vis, 30.0f, G)));
|
||||
}
|
||||
AssembleConsoleLayout(L, cache, all(), getRaw, 30.0f, H, G, m); // filter cleared
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 30.0f, G)));
|
||||
EXPECT_EQ(cache.wrapCount() - before, static_cast<std::size_t>(0));
|
||||
model.pop_front(); ++firstSeq; // at the cap: evict 1, add 1
|
||||
model.push_back(lineText(204) + "e");
|
||||
before = cache.wrapCount();
|
||||
cache.sync(firstSeq, model.size(), narrow);
|
||||
const std::vector<int> vis = filtered(hasE);
|
||||
AssembleConsoleLayout(L, cache, vis, getRaw, 30.0f, H, G, m);
|
||||
EXPECT_EQ(cache.wrapCount() - before, static_cast<std::size_t>(1));
|
||||
EXPECT_TRUE(sameAsLegacy(L, legacy(vis, 30.0f, G)));
|
||||
}
|
||||
|
||||
// 6) Filtered / folded subsets wrap lazily; a window jump restarts; reset() forgets everything.
|
||||
{
|
||||
ConsoleWrapCache c2;
|
||||
ConsoleLayout L2;
|
||||
c2.sync(firstSeq, model.size(), key);
|
||||
const std::vector<int> sub = {0, 5, 9};
|
||||
AssembleConsoleLayout(L2, c2, sub, getRaw, 50.0f, H, G, m);
|
||||
EXPECT_EQ(c2.wrapCount(), static_cast<std::size_t>(3));
|
||||
EXPECT_TRUE(sameAsLegacy(L2, legacy(sub, 50.0f, G)));
|
||||
std::vector<int> ten;
|
||||
for (int i = 0; i < 10; ++i) ten.push_back(i);
|
||||
AssembleConsoleLayout(L2, c2, ten, getRaw, 50.0f, H, G, m);
|
||||
EXPECT_EQ(c2.wrapCount(), static_cast<std::size_t>(10)); // only the 7 newly visible lines
|
||||
EXPECT_TRUE(sameAsLegacy(L2, legacy(ten, 50.0f, G)));
|
||||
c2.sync(firstSeq + 5000, 10, key); // more evicted than cached
|
||||
EXPECT_EQ(c2.size(), static_cast<std::size_t>(10));
|
||||
before = c2.wrapCount();
|
||||
AssembleConsoleLayout(L2, c2, ten, getRaw, 50.0f, H, G, m);
|
||||
EXPECT_EQ(c2.wrapCount() - before, static_cast<std::size_t>(10));
|
||||
EXPECT_TRUE(sameAsLegacy(L2, legacy(ten, 50.0f, G)));
|
||||
c2.reset();
|
||||
EXPECT_FALSE(c2.matches(key));
|
||||
EXPECT_EQ(c2.size(), static_cast<std::size_t>(0));
|
||||
}
|
||||
|
||||
// 7) ConsoleModel::firstSeq: +1 per evicted line, +size() on clear(), untouched by fold toggles.
|
||||
{
|
||||
ConsoleModel cm(3);
|
||||
for (int i = 0; i < 5; ++i) cm.ingest(std::to_string(i), ConsoleChannel::None);
|
||||
cm.drain();
|
||||
EXPECT_EQ(cm.firstSeq(), static_cast<std::uint64_t>(2));
|
||||
cm.ingest("{", ConsoleChannel::JsonBrace, 2);
|
||||
cm.drain();
|
||||
EXPECT_EQ(cm.firstSeq(), static_cast<std::uint64_t>(3));
|
||||
cm.toggleCollapsed(2);
|
||||
EXPECT_EQ(cm.firstSeq(), static_cast<std::uint64_t>(3));
|
||||
cm.clear();
|
||||
EXPECT_EQ(cm.firstSeq(), static_cast<std::uint64_t>(6));
|
||||
}
|
||||
}
|
||||
|
||||
// ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog).
|
||||
void testConsoleScrollController()
|
||||
{
|
||||
@@ -14794,6 +15097,7 @@ int main()
|
||||
testDaemonLifecycleExecution();
|
||||
testDaemonLifecycleAdapters();
|
||||
testConsoleTextLayout();
|
||||
testConsoleWrapCache();
|
||||
testConsoleModel();
|
||||
testPortfolioHelpers();
|
||||
testMarketSeries();
|
||||
|
||||
Reference in New Issue
Block a user