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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user