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

@@ -460,6 +460,7 @@ set(APP_SOURCES
src/ui/windows/console_input_model.cpp src/ui/windows/console_input_model.cpp
src/ui/windows/console_output_model.cpp src/ui/windows/console_output_model.cpp
src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp
src/ui/windows/settings_window.cpp src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp src/ui/windows/about_dialog.cpp
@@ -1019,6 +1020,7 @@ if(BUILD_TESTING)
src/ui/windows/console_input_model.cpp src/ui/windows/console_input_model.cpp
src/ui/windows/console_output_model.cpp src/ui/windows/console_output_model.cpp
src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp
src/ui/windows/mining_benchmark.cpp src/ui/windows/mining_benchmark.cpp
src/ui/windows/mining_pool_panel.cpp src/ui/windows/mining_pool_panel.cpp
src/ui/windows/mining_tab_helpers.cpp src/ui/windows/mining_tab_helpers.cpp

View File

@@ -534,6 +534,21 @@ std::string extractCopyableToken(const std::string& text, int col)
std::string tok = text.substr(s, e - s + 1); std::string tok = text.substr(s, e - s + 1);
return tok.size() >= 16 ? tok : std::string(); // txids/blockhashes/addresses are long 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 } // namespace
void ConsoleTab::renderOutput() void ConsoleTab::renderOutput()
@@ -543,8 +558,8 @@ void ConsoleTab::renderOutput()
std::lock_guard<std::mutex> lock(lines_mutex_); std::lock_guard<std::mutex> lock(lines_mutex_);
// Zero item spacing so Dummy items advance the cursor by exactly their // Zero item spacing so Dummy items advance the cursor by exactly their
// height. The inter-line gap is added explicitly to wrapped_heights_ // height. The inter-line gap is added explicitly to layout_.heights
// so that cumulative_y_offsets_ stays perfectly in sync with actual // so that layout_.cumulativeY stays perfectly in sync with actual
// cursor positions (avoiding selection-offset drift). // cursor positions (avoiding selection-offset drift).
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f); float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
@@ -601,60 +616,11 @@ void ConsoleTab::renderOutput()
ImFont* font = ImGui::GetFont(); ImFont* font = ImGui::GetFont();
float fontSize = ImGui::GetFontSize(); float fontSize = ImGui::GetFontSize();
wrapped_heights_.resize(visible_count); ImFontConsoleMeasure measure(font, fontSize);
cumulative_y_offsets_.resize(visible_count); layout_ = BuildConsoleLayout(
visible_wrap_segments_.resize(visible_count); visible_count,
total_wrapped_height_ = 0.0f; [this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; },
cached_wrap_width_ = wrap_width; wrap_width, line_height, interLineGap, measure);
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];
}
// Use raw IO for mouse handling to bypass child window event consumption // Use raw IO for mouse handling to bypass child window event consumption
ImGuiIO& io = ImGui::GetIO(); ImGuiIO& io = ImGui::GetIO();
@@ -740,11 +706,11 @@ void ConsoleTab::renderOutput()
// Find first visible line using binary search // Find first visible line using binary search
int first_visible = 0; int first_visible = 0;
if (!cumulative_y_offsets_.empty()) { if (!layout_.cumulativeY.empty()) {
int lo = 0, hi = static_cast<int>(cumulative_y_offsets_.size()) - 1; int lo = 0, hi = static_cast<int>(layout_.cumulativeY.size()) - 1;
while (lo < hi) { while (lo < hi) {
int mid = (lo + hi) / 2; 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) { if (line_bottom < visible_top) {
lo = mid + 1; lo = mid + 1;
} else { } else {
@@ -755,8 +721,8 @@ void ConsoleTab::renderOutput()
} }
// Add invisible spacer for lines before first visible (for correct scroll) // Add invisible spacer for lines before first visible (for correct scroll)
if (first_visible > 0 && first_visible < static_cast<int>(cumulative_y_offsets_.size())) { if (first_visible > 0 && first_visible < static_cast<int>(layout_.cumulativeY.size())) {
ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible])); ImGui::Dummy(ImVec2(0, layout_.cumulativeY[first_visible]));
} }
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
@@ -765,17 +731,17 @@ void ConsoleTab::renderOutput()
// Render visible lines // Render visible lines
int last_rendered_vi = first_visible - 1; int last_rendered_vi = first_visible - 1;
for (int vi = first_visible; vi < visible_count; vi++) { for (int vi = first_visible; vi < visible_count; vi++) {
if (vi < static_cast<int>(cumulative_y_offsets_.size()) && if (vi < static_cast<int>(layout_.cumulativeY.size()) &&
cumulative_y_offsets_[vi] > visible_bottom) { layout_.cumulativeY[vi] > visible_bottom) {
break; break;
} }
last_rendered_vi = vi; last_rendered_vi = vi;
int i = visible_indices_[vi]; int i = visible_indices_[vi];
const auto& line = lines_[i]; const auto& line = lines_[i];
const auto& segs = visible_wrap_segments_[vi]; const auto& segs = layout_.segments[vi];
ImVec2 lineOrigin = ImGui::GetCursorScreenPos(); 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 // Left-edge channel accent bar, drawn in the padX margin so it never overlaps
// the text or the selection highlight. // the text or the selection highlight.
@@ -831,7 +797,7 @@ void ConsoleTab::renderOutput()
const char* segEnd = line.text.c_str() + seg.byteEnd; const char* segEnd = line.text.c_str() + seg.byteEnd;
if (s_scanline_enabled && line_height > 0.0f) { 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}); 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) // Add spacer for lines after last visible (to maintain correct content height)
if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) { if (last_rendered_vi >= 0 && last_rendered_vi < visible_count - 1) {
float rendered_height = (last_rendered_vi < static_cast<int>(cumulative_y_offsets_.size())) float rendered_height = (last_rendered_vi < static_cast<int>(layout_.cumulativeY.size()))
? cumulative_y_offsets_[last_rendered_vi] + wrapped_heights_[last_rendered_vi] ? layout_.cumulativeY[last_rendered_vi] + layout_.heights[last_rendered_vi]
: 0.0f; : 0.0f;
float remaining_height = total_wrapped_height_ - rendered_height; float remaining_height = layout_.totalHeight - rendered_height;
if (remaining_height > 0) { if (remaining_height > 0) {
ImGui::Dummy(ImVec2(0, remaining_height)); ImGui::Dummy(ImVec2(0, remaining_height));
} }
@@ -1017,80 +983,17 @@ void ConsoleTab::renderOutput()
ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const
{ {
if (visible_indices_.empty()) return {0, 0};
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; TextPos pos;
pos.line = visible_indices_[hit.visibleRow];
if (visible_indices_.empty()) { pos.col = hit.col;
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;
// 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;
}
}
return pos; return pos;
} }
@@ -1114,46 +1017,13 @@ ConsoleTab::TextPos ConsoleTab::selectionEnd() const
std::string ConsoleTab::getSelectedText() const std::string ConsoleTab::getSelectedText() const
{ {
if (!has_selection_) return ""; if (!has_selection_) return "";
TextPos start = selectionStart(); TextPos start = selectionStart();
TextPos end = selectionEnd(); TextPos end = selectionEnd();
return ExtractConsoleSelection(
if (start.line < 0 || start.line >= static_cast<int>(lines_.size())) return ""; static_cast<int>(lines_.size()),
[this](int i) -> const std::string& { return lines_[i].text; },
// Build a set of visible line indices for quick lookup visible_indices_,
// (Only copy visible lines when filtering is active) {start.line, start.col}, {end.line, end.col});
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;
} }
void ConsoleTab::clearSelection() void ConsoleTab::clearSelection()

View File

@@ -4,6 +4,7 @@
#pragma once #pragma once
#include "console_text_layout.h"
#include "../layout.h" #include "../layout.h"
#include "../../daemon/embedded_daemon.h" #include "../../daemon/embedded_daemon.h"
#include "../../daemon/xmrig_manager.h" #include "../../daemon/xmrig_manager.h"
@@ -148,22 +149,10 @@ private:
std::string context_token_; // hash/address under the cursor at right-click std::string context_token_; // hash/address under the cursor at right-click
mutable std::vector<int> visible_indices_; // Cached for selection mapping mutable std::vector<int> visible_indices_; // Cached for selection mapping
// Wrapped line height caching (for variable-height text wrapping) // Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
mutable std::vector<float> wrapped_heights_; // Height of each visible line (accounts for wrapping) // recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
mutable std::vector<float> cumulative_y_offsets_; // Cumulative Y offset for each visible line // consumed by the renderer + hit-testing.
mutable float total_wrapped_height_ = 0.0f; // Total height of all visible lines mutable ConsoleLayout layout_;
mutable float cached_wrap_width_ = 0.0f; // Wrap width used for cached heights
// Sub-row layout: each visible line is split into wrap segments so
// selection and hit-testing know the exact screen position of every
// character.
struct WrapSegment {
int byteStart; // byte offset into ConsoleLine::text
int byteEnd; // byte offset past last char in this segment
float yOffset; // Y offset of this segment relative to the line's top
float height; // visual height of this segment
};
mutable std::vector<std::vector<WrapSegment>> visible_wrap_segments_; // [vi] -> segments
// Commands popup // Commands popup
bool show_commands_popup_ = false; bool show_commands_popup_ = false;

View 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

View File

@@ -0,0 +1,72 @@
// 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 <functional>
#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
};
// The wrap layout for a set of visible lines.
struct ConsoleLayout {
std::vector<std::vector<ConsoleWrapSegment>> segments; // per visible line
std::vector<float> heights; // per visible line (includes the inter-line gap)
std::vector<float> cumulativeY; // cumulative Y offset of each visible line
float totalHeight = 0.0f;
};
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)).
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
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

View File

@@ -15,6 +15,7 @@
#include "ui/windows/console_input_model.h" #include "ui/windows/console_input_model.h"
#include "ui/windows/console_output_model.h" #include "ui/windows/console_output_model.h"
#include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_tab_helpers.h"
#include "ui/windows/console_text_layout.h"
#include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h" #include "ui/windows/mining_tab_helpers.h"
@@ -2282,6 +2283,59 @@ void testDaemonLifecycleAdapters()
fs::remove_all(dataDir); fs::remove_all(dataDir);
} }
// Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a
// fixed-width measurement stub so the byte-offset selection math is unit-testable.
void testConsoleTextLayout()
{
using namespace dragonx::ui;
struct StubMeasure : ConsoleTextMeasure {
float charW = 10.0f;
float width(const char* b, const char* e) const override {
return static_cast<float>(e - b) * charW;
}
const char* wrapPosition(const char* b, const char* e, float w) const override {
int maxChars = static_cast<int>(w / charW);
if (maxChars < 1) maxChars = 1;
return (e - b) <= maxChars ? e : b + maxChars;
}
} stub;
std::vector<std::string> lines = { "", "abc", "abcdefghij" }; // 0, 3, 10 chars
auto get = [&lines](int i) -> const std::string& { return lines[i]; };
const float lineH = 12.0f, gap = 2.0f;
// wrapWidth 50 (charW 10 -> 5 chars/row)
ConsoleLayout L = BuildConsoleLayout(static_cast<int>(lines.size()), get, 50.0f, lineH, gap, stub);
EXPECT_EQ(L.segments.size(), static_cast<size_t>(3));
EXPECT_EQ(L.segments[0].size(), static_cast<size_t>(1)); // empty -> one empty seg
EXPECT_EQ(L.segments[0][0].byteEnd, 0);
EXPECT_NEAR(L.heights[0], lineH + gap, 0.01);
EXPECT_EQ(L.segments[1].size(), static_cast<size_t>(1)); // "abc" fits
EXPECT_EQ(L.segments[1][0].byteEnd, 3);
EXPECT_EQ(L.segments[2].size(), static_cast<size_t>(2)); // 10 chars, 5/row -> 2 rows
EXPECT_EQ(L.segments[2][0].byteEnd, 5);
EXPECT_EQ(L.segments[2][1].byteStart, 5);
EXPECT_EQ(L.segments[2][1].byteEnd, 10);
EXPECT_NEAR(L.heights[2], 2 * lineH + gap, 0.01);
EXPECT_NEAR(L.cumulativeY[2], 2 * (lineH + gap), 0.01);
EXPECT_NEAR(L.totalHeight, 3 * (lineH + gap) + lineH, 0.01);
// Hit-test: second wrap row of line 2, column 7.
ConsoleHit hit = HitTestConsoleLayout(L, 3, get, /*relX*/ 20.0f, /*relY*/ 45.0f, stub);
EXPECT_EQ(hit.visibleRow, 2);
EXPECT_EQ(hit.col, 7);
// Past the bottom clamps to the last line's end.
ConsoleHit last = HitTestConsoleLayout(L, 3, get, 1000.0f, 9999.0f, stub);
EXPECT_EQ(last.visibleRow, 2);
EXPECT_EQ(last.col, 10);
// Selection extraction (multi-line, no filter, then filtered to line 0 only).
std::vector<std::string> lines2 = { "hello", "world" };
auto get2 = [&lines2](int i) -> const std::string& { return lines2[i]; };
EXPECT_EQ(ExtractConsoleSelection(2, get2, {}, {0, 1}, {1, 3}), std::string("ello\nwor"));
EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
}
void testRendererHelpers() void testRendererHelpers()
{ {
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f), EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
@@ -4959,6 +5013,7 @@ int main()
testDaemonShutdownPolicy(); testDaemonShutdownPolicy();
testDaemonLifecycleExecution(); testDaemonLifecycleExecution();
testDaemonLifecycleAdapters(); testDaemonLifecycleAdapters();
testConsoleTextLayout();
testRendererHelpers(); testRendererHelpers();
testConsoleInputModel(); testConsoleInputModel();
testMiningBenchmarkModel(); testMiningBenchmarkModel();