diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp index a17d085..a5cfa17 100644 --- a/src/ui/windows/console_model.cpp +++ b/src/ui/windows/console_model.cpp @@ -36,6 +36,7 @@ ConsoleModel::DrainResult ConsoleModel::drain() while (lines_.size() > max_lines_) { lines_.pop_front(); ++result.popped; + ++first_seq_; } ++revision_; // deque changed — lets the view memoize its filter/layout passes return result; @@ -43,6 +44,7 @@ ConsoleModel::DrainResult ConsoleModel::drain() void ConsoleModel::clear() { + first_seq_ += lines_.size(); // the discarded lines keep their sequence numbers (view caches realign by seq) lines_.clear(); ++revision_; } diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h index aa402f2..552e92a 100644 --- a/src/ui/windows/console_model.h +++ b/src/ui/windows/console_model.h @@ -81,12 +81,18 @@ public: // fold toggle). The view memoizes its per-frame filter + text-layout passes against this. std::uint64_t revision() const { return revision_; } + // Absolute sequence number of lines_.front(): +1 per line evicted from the front by the cap, +size() on + // clear(). Line text never changes after ingest, so a view cache keyed by sequence number realigns with + // one subtraction (eviction becomes an offset, not a rebuild). Never decreases. + std::uint64_t firstSeq() const { return first_seq_; } + private: const std::size_t max_lines_; std::deque lines_; // visible model — main thread only std::vector pending_; // guarded by ingest_mutex_ std::mutex ingest_mutex_; std::uint64_t revision_ = 0; + std::uint64_t first_seq_ = 0; // see firstSeq() }; } // namespace ui diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 02c9792..16f4a3c 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -829,11 +829,25 @@ void ConsoleTab::renderOutput() layoutKey = layoutKey * 131ull + static_cast(line_height * 16.0f); layoutKey = layoutKey * 131ull + static_cast(interLineGap * 16.0f); layoutKey = layoutKey * 131ull + static_cast(s_console_zoom * 1000.0f); - if (layoutKey != layout_key_) { - ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); - layout_ = BuildConsoleLayout( - static_cast(visible_indices_.size()), - [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, + // Exact inputs of the per-line wrap (the gap is excluded: it is added at assembly, so a line-spacing change + // only re-sums heights). FontId, not the ImFont* address (an atlas reload can reuse it), catches a font reload + // at an unchanged size, e.g. the stb_truetype <-> FreeType swap of the chat colour-emoji toggle. + ImFont* outFont = ImGui::GetFont(); + ConsoleWrapKey wrapKey; + wrapKey.wrapWidth = wrap_width; + wrapKey.lineHeight = line_height; + wrapKey.fontSize = ImGui::GetFontSize(); + wrapKey.rasterDensity = outFont->CurrentRasterizerDensity; + wrapKey.font = outFont; + wrapKey.fontId = outFont->FontId; + if (layoutKey != layout_key_ || !wrap_cache_.matches(wrapKey)) { + ImFontConsoleMeasure measure(outFont, ImGui::GetFontSize()); + // Align the per-line cache with the model (evicted prefix dropped by sequence number, new lines appended, + // everything invalidated on a geometry/font change), then re-point layout_ at it: only lines not yet + // wrapped at this geometry are measured; the rest is an O(visible) prefix sum with no allocation. + wrap_cache_.sync(model_.firstSeq(), model_.size(), wrapKey); + AssembleConsoleLayout(layout_, wrap_cache_, visible_indices_, + [this](int raw) -> const std::string& { return model_[static_cast(raw)].text; }, wrap_width, line_height, interLineGap, measure); layout_key_ = layoutKey; } @@ -1042,8 +1056,9 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt // gap) and skipped rows, breaking the alternation. Seed it with the sub-row ordinal of // the first on-screen line so the shading stays stable across scrolling. int scanRowOrdinal = 0; - for (int k = 0; k < first_visible && k < static_cast(layout_.segments.size()); k++) - scanRowOrdinal += static_cast(layout_.segments[k].size()); + if (!layout_.rowStart.empty()) // prefix sub-row counts from AssembleConsoleLayout: same value as the old sum + scanRowOrdinal = layout_.rowStart[std::min(static_cast(first_visible), + layout_.rowStart.size() - 1)]; int last_rendered_vi = first_visible - 1; for (int vi = first_visible; vi < visible_count; vi++) { @@ -2112,6 +2127,8 @@ void ConsoleTab::clear() vis_key_ = ~0ull; layout_key_ = ~0ull; ++vis_generation_; + layout_ = ConsoleLayout{}; // drop the refs into wrap_cache_ BEFORE resetting it (no dangling segments) + wrap_cache_.reset(); stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index f7b8607..1c0f84c 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -195,11 +195,15 @@ private: // resulting visible-set generation + wrap width + line height/zoom. std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt - std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout + std::uint64_t layout_key_ = ~0ull; // key of the last layout assembly - // Wrap layout for the visible lines (segments + per-line heights + cumulative Y), - // recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and - // consumed by the renderer + hit-testing. + // Per-raw-line wrap cache (index == model_ index after sync). layout_.segments point INTO it, so it is only + // mutated by sync() immediately followed by AssembleConsoleLayout (renderOutput), or by reset() together + // with clearing layout_ (clear()). Touch it nowhere else. + ConsoleWrapCache wrap_cache_; + + // Wrap layout for the visible lines (segments + per-line heights + cumulative Y), re-assembled from + // wrap_cache_ when the visible set / geometry changes, and consumed by the renderer + hit-testing. mutable ConsoleLayout layout_; // Commands popup (command explorer) — indexes into the active executor's commandReference() diff --git a/src/ui/windows/console_text_layout.cpp b/src/ui/windows/console_text_layout.cpp index 610f589..c3e16c9 100644 --- a/src/ui/windows/console_text_layout.cpp +++ b/src/ui/windows/console_text_layout.cpp @@ -9,63 +9,127 @@ #include "console_text_layout.h" #include +#include namespace dragonx { namespace ui { +void WrapConsoleLine(const std::string& text, float wrapWidth, float lineHeight, + const ConsoleTextMeasure& measure, ConsoleWrapLine& out) +{ + auto& segs = out.segs; + segs.clear(); + out.wrapped = true; + + if (text.empty()) { + segs.push_back({0, 0, 0.0f, lineHeight}); + out.heightNoGap = lineHeight; + return; + } + + 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(cur - textStart); + int byteEnd = static_cast(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; + } + out.heightNoGap = segY; +} + +void ConsoleWrapCache::sync(std::uint64_t modelFirstSeq, std::size_t modelSize, const ConsoleWrapKey& key) +{ + if (!key_valid_ || modelFirstSeq < first_seq_ || modelFirstSeq - first_seq_ > lines_.size()) { + lines_.clear(); // first use / reset() / the window jumped past us + } else { + for (std::uint64_t n = modelFirstSeq - first_seq_; n > 0; --n) lines_.pop_front(); // cap eviction = offset + } + first_seq_ = modelFirstSeq; + if (lines_.size() > modelSize) lines_.clear(); // the model never shrinks at the back: distrust, restart + if (!key_valid_ || key_ != key) { + for (ConsoleWrapLine& l : lines_) l.wrapped = false; // width/font/size changed: lazy re-wrap, keep capacity + key_ = key; + key_valid_ = true; + } + while (lines_.size() < modelSize) lines_.emplace_back(); // new lines start unwrapped +} + +const ConsoleWrapLine& ConsoleWrapCache::ensure(std::size_t raw, const ConsoleLineAccessor& getRawText, + float wrapWidth, float lineHeight, + const ConsoleTextMeasure& measure) +{ + while (raw >= lines_.size()) lines_.emplace_back(); // defensive: sync() already sized us to the model + ConsoleWrapLine& w = lines_[raw]; + if (!w.wrapped) { + WrapConsoleLine(getRawText(static_cast(raw)), wrapWidth, lineHeight, measure, w); + ++wrap_count_; + } + return w; +} + +void ConsoleWrapCache::reset() +{ + lines_.clear(); + key_ = ConsoleWrapKey{}; + key_valid_ = false; + first_seq_ = 0; +} + +void AssembleConsoleLayout(ConsoleLayout& out, ConsoleWrapCache& cache, const std::vector& visibleRaw, + const ConsoleLineAccessor& getRawText, float wrapWidth, float lineHeight, + float interLineGap, const ConsoleTextMeasure& measure) +{ + const std::size_t V = visibleRaw.size(); + out.owner.reset(); + out.segments.clear(); out.segments.reserve(V); + out.heights.clear(); out.heights.reserve(V); + out.cumulativeY.clear(); out.cumulativeY.reserve(V); + out.rowStart.clear(); out.rowStart.reserve(V + 1); + float total = 0.0f; + int rows = 0; + for (std::size_t vi = 0; vi < V; vi++) { + const ConsoleWrapLine& w = cache.ensure(static_cast(visibleRaw[vi]), getRawText, + wrapWidth, lineHeight, measure); + out.segments.push_back(ConsoleSegRef{&w.segs}); + out.rowStart.push_back(rows); + rows += static_cast(w.segs.size()); + out.cumulativeY.push_back(total); + const float h = w.heightNoGap + interLineGap; // same op order as the former builder => bit-identical + out.heights.push_back(h); + total += h; + } + out.rowStart.push_back(rows); + out.totalHeight = total; +} + 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(cur - textStart); - int byteEnd = static_cast(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]; - } - + auto cache = std::make_shared(); + cache->sync(0, static_cast(visibleCount), ConsoleWrapKey{}); + std::vector ids(static_cast(visibleCount)); + std::iota(ids.begin(), ids.end(), 0); + ConsoleLayout L; + AssembleConsoleLayout(L, *cache, ids, getVisibleText, wrapWidth, lineHeight, interLineGap, measure); + L.owner = cache; // segment refs point into the cache's deque nodes, which outlive moves/copies of L return L; } diff --git a/src/ui/windows/console_text_layout.h b/src/ui/windows/console_text_layout.h index 32ddcc3..c74a659 100644 --- a/src/ui/windows/console_text_layout.h +++ b/src/ui/windows/console_text_layout.h @@ -11,7 +11,11 @@ #pragma once +#include +#include +#include #include +#include #include #include @@ -26,12 +30,27 @@ struct ConsoleWrapSegment { float height = 0; // visual height of this sub-row }; +// Non-owning, read-only view of one line's wrap segments (points into a ConsoleWrapCache entry). Mirrors the +// subset of std::vector the readers use (size/empty/[]/back/range-for), so HitTestConsoleLayout, +// ConsoleTab::drawVisibleLines and the existing tests compile unchanged. begin()/end() are null-safe. +struct ConsoleSegRef { + const std::vector* v = nullptr; + std::size_t size() const { return v ? v->size() : 0; } + bool empty() const { return size() == 0; } + const ConsoleWrapSegment& operator[](std::size_t i) const { return (*v)[i]; } + const ConsoleWrapSegment& back() const { return v->back(); } + const ConsoleWrapSegment* begin() const { return v ? v->data() : nullptr; } + const ConsoleWrapSegment* end() const { return v ? v->data() + v->size() : nullptr; } +}; + // The wrap layout for a set of visible lines. struct ConsoleLayout { - std::vector> segments; // per visible line + std::vector segments; // per visible line; points into a ConsoleWrapCache (see owner) std::vector heights; // per visible line (includes the inter-line gap) std::vector cumulativeY; // cumulative Y offset of each visible line + std::vector rowStart; // V+1 entries: # wrap sub-rows before each visible line (scanline seed) float totalHeight = 0.0f; + std::shared_ptr owner; // keeps the cache alive for BuildConsoleLayout results; null in ConsoleTab }; struct ConsoleTextPos { int line = -1; int col = 0; }; // line = raw index; col = byte offset @@ -50,11 +69,75 @@ struct ConsoleTextMeasure { // Accessor returning a visible/raw line's text by index (avoids copying the buffer). using ConsoleLineAccessor = std::function; -// Build the wrap layout for `visibleCount` lines (text via getVisibleText(0..N-1)). +// Build the wrap layout for `visibleCount` lines (text via getVisibleText(0..N-1)). One-shot: fresh cache, every +// line wrapped once. ConsoleTab uses the incremental ConsoleWrapCache + AssembleConsoleLayout path below. ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText, float wrapWidth, float lineHeight, float interLineGap, const ConsoleTextMeasure& measure); +// Exact inputs of WrapConsoleLine besides the text. Compared with == (never quantized: a quantized key would keep +// old lines wrapped at the old geometry next to new lines wrapped at the new one, forever). The inter-line gap is +// deliberately absent (added at assembly). `fontId` is ImFont::FontId: unique per AddFont and never reused across +// an atlas Clear()/reload, unlike the ImFont* address (Typography::reload re-adds fonts in the same order/sizes). +struct ConsoleWrapKey { + float wrapWidth = -1.0f; + float lineHeight = -1.0f; + float fontSize = -1.0f; + float rasterDensity = -1.0f; + const void* font = nullptr; + std::uint32_t fontId = 0; + bool operator==(const ConsoleWrapKey& o) const { + return wrapWidth == o.wrapWidth && lineHeight == o.lineHeight && fontSize == o.fontSize && + rasterDensity == o.rasterDensity && font == o.font && fontId == o.fontId; + } + bool operator!=(const ConsoleWrapKey& o) const { return !(*this == o); } +}; + +// One raw line's cached wrap. +struct ConsoleWrapLine { + std::vector segs; + float heightNoGap = 0.0f; // sum of sub-row heights (the former builder's segY); gap added at assembly + bool wrapped = false; // false = never wrapped, or invalidated by a key change +}; + +// Per-RAW-line wrap cache aligned with a ConsoleModel window by absolute sequence number (index == raw model index +// after sync()). Main thread only. ConsoleLayout::segments point INTO it, so every sync()/reset() must be followed +// by AssembleConsoleLayout (or by clearing the layout) before the layout is read again. Appends (emplace_back) and +// front evictions (pop_front) leave the other entries in place. +class ConsoleWrapCache { +public: + // Align with the model window [modelFirstSeq, modelFirstSeq + modelSize): drop the evicted prefix (O(popped)), + // append unwrapped entries for new lines (O(added)), mark every entry unwrapped when `key` changed (keeping the + // vectors' capacity), and start over when the window does not chain with the cached one. + void sync(std::uint64_t modelFirstSeq, std::size_t modelSize, const ConsoleWrapKey& key); + // Wrap-on-demand: calls getRawText(raw) + WrapConsoleLine only when the entry is not wrapped. The geometry and + // measure passed here must be the ones the synced key describes. + const ConsoleWrapLine& ensure(std::size_t raw, const ConsoleLineAccessor& getRawText, float wrapWidth, + float lineHeight, const ConsoleTextMeasure& measure); + bool matches(const ConsoleWrapKey& key) const { return key_valid_ && key_ == key; } + void reset(); // forget everything (ConsoleTab::clear) + std::size_t wrapCount() const { return wrap_count_; } // lifetime WrapConsoleLine calls (tests) + std::size_t size() const { return lines_.size(); } + +private: + ConsoleWrapKey key_; + bool key_valid_ = false; + std::uint64_t first_seq_ = 0; + std::size_t wrap_count_ = 0; + std::deque lines_; +}; + +// Wrap one line (the exact former loop body of BuildConsoleLayout). +void WrapConsoleLine(const std::string& text, float wrapWidth, float lineHeight, + const ConsoleTextMeasure& measure, ConsoleWrapLine& out); + +// Re-point `out` at the cached wraps of `visibleRaw` (raw model indices, ascending), wrapping only uncached lines, +// and recompute heights / cumulativeY / rowStart / totalHeight with a fresh prefix sum (same float ops in the same +// order as the former builder => bit-identical). Reuses out's capacity: no per-line allocation. +void AssembleConsoleLayout(ConsoleLayout& out, ConsoleWrapCache& cache, const std::vector& visibleRaw, + const ConsoleLineAccessor& getRawText, float wrapWidth, float lineHeight, + float interLineGap, const ConsoleTextMeasure& measure); + // Map a point relative to the layout origin to a (visibleRow, byte col). The caller maps // visibleRow -> a raw line index. Returns {0,0} when there are no visible lines. ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount, diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 54981de..4814b37 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -5555,6 +5555,309 @@ void testConsoleTextLayout() EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector{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> segments; // per visible line + std::vector heights; // per visible line (includes the inter-line gap) + std::vector 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(cur - textStart); + int byteEnd = static_cast(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(e - b) * charW; + } + const char* wrapPosition(const char* b, const char* e, float w) const override { + ++calls; + int maxChars = static_cast(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(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('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 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(i)]; }; + auto all = [&model]() { + std::vector v; + for (int i = 0; i < static_cast(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(model.size()), getRaw, w, H, gap, m); + }; + CountingMeasure refM; // the reference's own measure: never perturbs m.calls + auto legacy = [&](const std::vector& vis, float w, float gap) { + auto getVis = [&](int vi) -> const std::string& { + return model[static_cast(vis[static_cast(vi)])]; + }; + return cwlegacy::BuildConsoleLayout(static_cast(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(200)); + EXPECT_EQ(L.rowStart.size(), static_cast(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(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(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(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(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(model.size()), getRaw, p[0], p[1], m); + ConsoleHit b = HitTestConsoleLayout(ref, static_cast(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 v; + for (int i = 0; i < static_cast(model.size()); ++i) + if (pred(model[static_cast(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 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(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 vis = filtered(hasE); + AssembleConsoleLayout(L, cache, vis, getRaw, 30.0f, H, G, m); + EXPECT_EQ(cache.wrapCount() - before, static_cast(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 sub = {0, 5, 9}; + AssembleConsoleLayout(L2, c2, sub, getRaw, 50.0f, H, G, m); + EXPECT_EQ(c2.wrapCount(), static_cast(3)); + EXPECT_TRUE(sameAsLegacy(L2, legacy(sub, 50.0f, G))); + std::vector 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(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(10)); + before = c2.wrapCount(); + AssembleConsoleLayout(L2, c2, ten, getRaw, 50.0f, H, G, m); + EXPECT_EQ(c2.wrapCount() - before, static_cast(10)); + EXPECT_TRUE(sameAsLegacy(L2, legacy(ten, 50.0f, G))); + c2.reset(); + EXPECT_FALSE(c2.matches(key)); + EXPECT_EQ(c2.size(), static_cast(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(2)); + cm.ingest("{", ConsoleChannel::JsonBrace, 2); + cm.drain(); + EXPECT_EQ(cm.firstSeq(), static_cast(3)); + cm.toggleCollapsed(2); + EXPECT_EQ(cm.firstSeq(), static_cast(3)); + cm.clear(); + EXPECT_EQ(cm.firstSeq(), static_cast(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();