Files
ObsidianDragon/src/ui/windows/console_text_layout.cpp
T
DanSandClaude Opus 5.5 ee0a486676 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>
2026-10-10 09:50:03 -05:00

227 lines
8.7 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure implementation of the console wrap layout / hit-test / selection. No ImGui — the
// algorithm mirrors the former inline code in ConsoleTab exactly, with text measurement
// injected via ConsoleTextMeasure.
#include "console_text_layout.h"
#include <algorithm>
#include <numeric>
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<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;
}
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<int>(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<int>& 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<std::size_t>(visibleRaw[vi]), getRawText,
wrapWidth, lineHeight, measure);
out.segments.push_back(ConsoleSegRef{&w.segs});
out.rowStart.push_back(rows);
rows += static_cast<int>(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)
{
if (visibleCount < 0) visibleCount = 0;
auto cache = std::make_shared<ConsoleWrapCache>();
cache->sync(0, static_cast<std::size_t>(visibleCount), ConsoleWrapKey{});
std::vector<int> ids(static_cast<std::size_t>(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;
}
ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount,
const ConsoleLineAccessor& getVisibleText,
float relX, float relY,
const ConsoleTextMeasure& measure)
{
ConsoleHit hit{0, 0};
if (visibleCount <= 0 || layout.cumulativeY.empty()) return hit;
// Binary search for the visible line containing relY.
int visibleLine = 0;
{
int lo = 0, hi = static_cast<int>(layout.cumulativeY.size()) - 1;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (layout.cumulativeY[mid] <= relY) lo = mid;
else hi = mid - 1;
}
visibleLine = lo;
}
if (visibleLine < 0) visibleLine = 0;
if (visibleLine >= visibleCount) {
visibleLine = visibleCount - 1;
hit.visibleRow = visibleLine;
hit.col = static_cast<int>(getVisibleText(visibleLine).size());
return hit;
}
hit.visibleRow = visibleLine;
const std::string& text = getVisibleText(visibleLine);
if (text.empty()) { hit.col = 0; return hit; }
// Which wrap sub-row does relY fall into.
const auto& segs = layout.segments[visibleLine];
float lineRelY = relY - layout.cumulativeY[visibleLine];
int segIdx = 0;
for (int s = 0; s < static_cast<int>(segs.size()); s++)
if (lineRelY >= segs[s].yOffset) segIdx = s;
const auto& seg = segs[segIdx];
if (relX <= 0.0f) { hit.col = seg.byteStart; return hit; }
const char* segStart = text.c_str() + seg.byteStart;
int segLen = seg.byteEnd - seg.byteStart;
hit.col = seg.byteEnd; // default: past the end of the segment
for (int c = 0; c < segLen; c++) {
float wCur = measure.width(segStart, segStart + c + 1);
float wPrev = (c > 0) ? measure.width(segStart, segStart + c) : 0.0f;
float charMid = (wPrev + wCur) * 0.5f;
if (relX < charMid) { hit.col = seg.byteStart + c; return hit; }
}
return hit;
}
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices,
ConsoleTextPos start, ConsoleTextPos end)
{
if (start.line < 0 || start.line >= lineCount) return "";
// Only restrict to visible lines when a filter is active (fewer visible than total).
const bool hasFilter = !visibleIndices.empty() &&
static_cast<int>(visibleIndices.size()) < lineCount;
std::vector<bool> visible;
if (hasFilter) {
visible.assign(lineCount, false);
for (int idx : visibleIndices)
if (idx >= 0 && idx < lineCount) visible[idx] = true;
}
std::string result;
bool firstLine = true;
for (int i = start.line; i <= end.line && i < lineCount; i++) {
if (hasFilter && !visible[i]) continue;
const std::string& text = getLine(i);
int colStart = 0;
int colEnd = static_cast<int>(text.size());
if (i == start.line) colStart = std::min(start.col, static_cast<int>(text.size()));
if (i == end.line) colEnd = std::min(end.col, static_cast<int>(text.size()));
if (!firstLine) result += "\n";
firstLine = false;
if (colStart < colEnd) result += text.substr(colStart, colEnd - colStart);
}
return result;
}
} // namespace ui
} // namespace dragonx