Files
ObsidianDragon/src/ui/windows/console_text_layout.h
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

156 lines
8.7 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleTextLayout — pure (ImGui-free) word-wrap layout + hit-testing + selection
// extraction for the console output. Text measurement is injected via ConsoleTextMeasure
// so the production path uses ImFont exactly as before while tests drive it with a
// fixed-width stub. Extracted from ConsoleTab::renderOutput / screenToTextPos /
// getSelectedText so the trickiest console logic (byte-offset selection across wrapped
// rows) becomes unit-testable.
#pragma once
#include <cstddef>
#include <cstdint>
#include <deque>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
// One visual sub-row of a (possibly wrapped) logical line.
struct ConsoleWrapSegment {
int byteStart = 0; // byte offset into the line's text
int byteEnd = 0; // byte offset past the last char on this sub-row
float yOffset = 0; // Y offset of this sub-row relative to the line's top
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<ConsoleWrapSegment>* 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<ConsoleSegRef> segments; // per visible line; points into a ConsoleWrapCache (see owner)
std::vector<float> heights; // per visible line (includes the inter-line gap)
std::vector<float> cumulativeY; // cumulative Y offset of each visible line
std::vector<int> rowStart; // V+1 entries: # wrap sub-rows before each visible line (scanline seed)
float totalHeight = 0.0f;
std::shared_ptr<const void> 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
struct ConsoleHit { int visibleRow = 0; int col = 0; }; // visibleRow = index into the visible set
// Text measurement backend. Production wraps ImFont; tests inject a fixed-width stub.
struct ConsoleTextMeasure {
virtual ~ConsoleTextMeasure() = default;
// Pixel width of the byte range [begin, end).
virtual float width(const char* begin, const char* end) const = 0;
// The byte position at which [begin, end) should wrap for the given width — mirrors
// ImFont::CalcWordWrapPositionA. Returns `end` when the whole range fits.
virtual const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const = 0;
};
// Accessor returning a visible/raw line's text by index (avoids copying the buffer).
using ConsoleLineAccessor = std::function<const std::string&(int)>;
// 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<ConsoleWrapSegment> 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<ConsoleWrapLine> 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<int>& 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,
const ConsoleLineAccessor& getVisibleText,
float relX, float relY,
const ConsoleTextMeasure& measure);
// Extract the selected text between raw positions [start, end], restricted to visible
// lines when a filter is active, joining lines with '\n'. Pure.
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices,
ConsoleTextPos start, ConsoleTextPos end);
} // namespace ui
} // namespace dragonx