refactor(console): phase 1 — extract pure ConsoleTextLayout (wrap/hit-test/selection)

Lift the console's word-wrap layout, screen->text hit-testing, and selection extraction
out of ConsoleTab into a pure, ImGui-free module (console_text_layout.{h,cpp}). Text
measurement is injected via a ConsoleTextMeasure interface — production uses an
ImFont-backed impl (identical CalcWordWrapPositionA/CalcTextSizeA calls as before), while
tests drive a fixed-width stub.

- BuildConsoleLayout: per-line wrap segments + heights + cumulative Y (was inline in
  renderOutput).
- HitTestConsoleLayout: point -> (visibleRow, byte col) (was screenToTextPos).
- ExtractConsoleSelection: selected text across visible lines (was getSelectedText).
- ConsoleTab now holds a single `ConsoleLayout layout_` (replacing 4 mutable members;
  drops the dead cached_wrap_width_) and calls the pure functions.
- New unit test testConsoleTextLayout covers wrapping, multi-row hit-testing, bottom
  clamp, and filtered/unfiltered selection — the biggest previously-untested surface.

No behavior change (algorithm preserved verbatim). Full-node + lite build clean, ctest
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:58:29 -05:00
parent f12153cdd0
commit 19e83eb12a
6 changed files with 348 additions and 198 deletions

View File

@@ -534,6 +534,21 @@ std::string extractCopyableToken(const std::string& text, int col)
std::string tok = text.substr(s, e - s + 1);
return tok.size() >= 16 ? tok : std::string(); // txids/blockhashes/addresses are long
}
// Production ConsoleTextMeasure backed by the active ImGui font (used for word-wrap +
// per-character positioning). The pure layout module is measured against this in prod and
// a fixed-width stub in tests.
struct ImFontConsoleMeasure : ConsoleTextMeasure {
ImFont* font;
float fontSize;
ImFontConsoleMeasure(ImFont* f, float s) : font(f), fontSize(s) {}
float width(const char* begin, const char* end) const override {
return font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, begin, end).x;
}
const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const override {
return font->CalcWordWrapPositionA(fontSize / font->LegacySize, begin, end, wrapWidth);
}
};
} // namespace
void ConsoleTab::renderOutput()
@@ -543,8 +558,8 @@ void ConsoleTab::renderOutput()
std::lock_guard<std::mutex> lock(lines_mutex_);
// Zero item spacing so Dummy items advance the cursor by exactly their
// height. The inter-line gap is added explicitly to wrapped_heights_
// so that cumulative_y_offsets_ stays perfectly in sync with actual
// height. The inter-line gap is added explicitly to layout_.heights
// so that layout_.cumulativeY stays perfectly in sync with actual
// cursor positions (avoiding selection-offset drift).
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
@@ -601,60 +616,11 @@ void ConsoleTab::renderOutput()
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
wrapped_heights_.resize(visible_count);
cumulative_y_offsets_.resize(visible_count);
visible_wrap_segments_.resize(visible_count);
total_wrapped_height_ = 0.0f;
cached_wrap_width_ = wrap_width;
for (int vi = 0; vi < visible_count; vi++) {
int i = visible_indices_[vi];
const std::string& text = lines_[i].text;
auto& segs = visible_wrap_segments_[vi];
segs.clear();
if (text.empty()) {
segs.push_back({0, 0, 0.0f, line_height});
cumulative_y_offsets_[vi] = total_wrapped_height_;
wrapped_heights_[vi] = line_height + interLineGap;
total_wrapped_height_ += wrapped_heights_[vi];
continue;
}
// Walk the text using ImFont::CalcWordWrapPositionA to find
// exactly where ImGui would break each visual row.
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 = font->CalcWordWrapPositionA(
fontSize / font->LegacySize, cur, textEnd, wrap_width);
// Ensure forward progress (at least one character)
if (wrapPos <= cur) wrapPos = cur + 1;
// Skip a leading newline character that ends the previous segment
if (*cur == '\n') { cur++; continue; }
int byteStart = static_cast<int>(cur - textStart);
int byteEnd = static_cast<int>(wrapPos - textStart);
// Trim trailing newline from this segment
if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--;
segs.push_back({byteStart, byteEnd, segY, line_height});
segY += line_height;
cur = wrapPos;
}
if (segs.empty()) {
segs.push_back({0, 0, 0.0f, line_height});
segY = line_height;
}
cumulative_y_offsets_[vi] = total_wrapped_height_;
wrapped_heights_[vi] = segY + interLineGap;
total_wrapped_height_ += wrapped_heights_[vi];
}
ImFontConsoleMeasure measure(font, fontSize);
layout_ = BuildConsoleLayout(
visible_count,
[this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; },
wrap_width, line_height, interLineGap, measure);
// Use raw IO for mouse handling to bypass child window event consumption
ImGuiIO& io = ImGui::GetIO();
@@ -740,11 +706,11 @@ void ConsoleTab::renderOutput()
// Find first visible line using binary search
int first_visible = 0;
if (!cumulative_y_offsets_.empty()) {
int lo = 0, hi = static_cast<int>(cumulative_y_offsets_.size()) - 1;
if (!layout_.cumulativeY.empty()) {
int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1;
while (lo < hi) {
int mid = (lo + hi) / 2;
float line_bottom = cumulative_y_offsets_[mid] + wrapped_heights_[mid];
float line_bottom = layout_.cumulativeY[mid] + layout_.heights[mid];
if (line_bottom < visible_top) {
lo = mid + 1;
} else {
@@ -755,8 +721,8 @@ void ConsoleTab::renderOutput()
}
// Add invisible spacer for lines before first visible (for correct scroll)
if (first_visible > 0 && first_visible < static_cast<int>(cumulative_y_offsets_.size())) {
ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible]));
if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) {
ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible]));
}
ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -765,17 +731,17 @@ void ConsoleTab::renderOutput()
// Render visible lines
int last_rendered_vi = first_visible - 1;
for (int vi = first_visible; vi < visible_count; vi++) {
if (vi < static_cast<int>(cumulative_y_offsets_.size()) &&
cumulative_y_offsets_[vi] > visible_bottom) {
if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
layout_.cumulativeY[vi] > visible_bottom) {
break;
}
last_rendered_vi = vi;
int i = visible_indices_[vi];
const auto& line = lines_[i];
const auto& segs = visible_wrap_segments_[vi];
const auto& segs = layout_.segments[vi];
ImVec2 lineOrigin = ImGui::GetCursorScreenPos();
float totalH = wrapped_heights_[vi];
float totalH = layout_.heights[vi];
// Left-edge channel accent bar, drawn in the padX margin so it never overlaps
// the text or the selection highlight.
@@ -831,7 +797,7 @@ void ConsoleTab::renderOutput()
const char* segEnd = line.text.c_str() + seg.byteEnd;
if (s_scanline_enabled && line_height > 0.0f) {
int rowIndex = static_cast<int>(std::floor((cumulative_y_offsets_[vi] + seg.yOffset) / line_height + 0.5f));
int rowIndex = static_cast<int>(std::floor((layout_.cumulativeY[vi] + seg.yOffset) / line_height + 0.5f));
scanline_rows_.push_back({rowY, rowY + seg.height, rowIndex});
}
@@ -890,10 +856,10 @@ void ConsoleTab::renderOutput()
// Add spacer for lines after last visible (to maintain correct content height)
if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) {
float rendered_height = (last_rendered_vi < static_cast<int>(cumulative_y_offsets_.size()))
? cumulative_y_offsets_[last_rendered_vi] + wrapped_heights_[last_rendered_vi]
float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size()))
? layout_.cumulativeY[last_rendered_vi] + layout_.heights[last_rendered_vi]
: 0.0f;
float remaining_height = total_wrapped_height_ - rendered_height;
float remaining_height = layout_.totalHeight - rendered_height;
if (remaining_height > 0) {
ImGui::Dummy(ImVec2(0, remaining_height));
}
@@ -1017,80 +983,17 @@ void ConsoleTab::renderOutput()
ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const
{
TextPos pos;
if (visible_indices_.empty()) {
return {0, 0};
}
float relative_y = screen_pos.y - output_origin_.y;
// Binary search for the visible line that contains this Y position
int visible_line = 0;
if (!cumulative_y_offsets_.empty()) {
int lo = 0, hi = static_cast<int>(cumulative_y_offsets_.size()) - 1;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (cumulative_y_offsets_[mid] <= relative_y) {
lo = mid;
} else {
hi = mid - 1;
}
}
visible_line = lo;
}
// Clamp visible line
if (visible_line < 0) visible_line = 0;
if (visible_line >= static_cast<int>(visible_indices_.size())) {
visible_line = static_cast<int>(visible_indices_.size()) - 1;
pos.line = visible_indices_[visible_line];
pos.col = static_cast<int>(lines_[pos.line].text.size());
return pos;
}
pos.line = visible_indices_[visible_line];
const std::string& text = lines_[pos.line].text;
if (text.empty()) {
pos.col = 0;
return pos;
}
// Find which sub-row (wrap segment) the mouse Y falls into
const auto& segs = visible_wrap_segments_[visible_line];
float lineRelY = relative_y - cumulative_y_offsets_[visible_line];
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];
// Calculate column within this segment from X position
float relative_x = screen_pos.x - output_origin_.x;
if (relative_x <= 0.0f) {
pos.col = seg.byteStart;
return pos;
}
ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize();
const char* segStart = text.c_str() + seg.byteStart;
int segLen = seg.byteEnd - seg.byteStart;
if (visible_indices_.empty()) return {0, 0};
// Walk characters within this segment for accurate positioning
pos.col = seg.byteEnd; // default: past end of segment
for (int c = 0; c < segLen; c++) {
float wCur = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c + 1).x;
float wPrev = (c > 0) ? font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c).x : 0.0f;
float charMid = (wPrev + wCur) * 0.5f;
if (relative_x < charMid) {
pos.col = seg.byteStart + c;
return pos;
}
}
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
ConsoleHit hit = HitTestConsoleLayout(
layout_, static_cast<int>(visible_indices_.size()),
[this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; },
screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure);
TextPos pos;
pos.line = visible_indices_[hit.visibleRow];
pos.col = hit.col;
return pos;
}
@@ -1114,46 +1017,13 @@ ConsoleTab::TextPos ConsoleTab::selectionEnd() const
std::string ConsoleTab::getSelectedText() const
{
if (!has_selection_) return "";
TextPos start = selectionStart();
TextPos end = selectionEnd();
if (start.line < 0 || start.line >= static_cast<int>(lines_.size())) return "";
// Build a set of visible line indices for quick lookup
// (Only copy visible lines when filtering is active)
std::unordered_set<int> visible_set(visible_indices_.begin(), visible_indices_.end());
bool has_filter = !visible_indices_.empty() && visible_indices_.size() < lines_.size();
std::string result;
bool first_line = true;
for (int i = start.line; i <= end.line && i < static_cast<int>(lines_.size()); i++) {
// Skip lines that aren't visible when filtering
if (has_filter && visible_set.find(i) == visible_set.end()) {
continue;
}
const std::string& text = lines_[i].text;
int col_start = 0;
int col_end = static_cast<int>(text.size());
if (i == start.line) col_start = std::min(start.col, static_cast<int>(text.size()));
if (i == end.line) col_end = std::min(end.col, static_cast<int>(text.size()));
// Add newline between lines (but not before first)
if (!first_line) {
result += "\n";
}
first_line = false;
if (col_start < col_end) {
result += text.substr(col_start, col_end - col_start);
}
}
return result;
return ExtractConsoleSelection(
static_cast<int>(lines_.size()),
[this](int i) -> const std::string& { return lines_[i].text; },
visible_indices_,
{start.line, start.col}, {end.line, end.col});
}
void ConsoleTab::clearSelection()