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:
162
src/ui/windows/console_text_layout.cpp
Normal file
162
src/ui/windows/console_text_layout.cpp
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
L.cumulativeY[vi] = L.totalHeight;
|
||||
L.heights[vi] = segY + interLineGap;
|
||||
L.totalHeight += L.heights[vi];
|
||||
}
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user