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:
@@ -460,6 +460,7 @@ set(APP_SOURCES
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/console_text_layout.cpp
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.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_output_model.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_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "console_text_layout.h"
|
||||
#include "../layout.h"
|
||||
#include "../../daemon/embedded_daemon.h"
|
||||
#include "../../daemon/xmrig_manager.h"
|
||||
@@ -147,24 +148,12 @@ private:
|
||||
mutable int filter_match_count_ = 0; // lines matching the text filter (for the toolbar)
|
||||
std::string context_token_; // hash/address under the cursor at right-click
|
||||
mutable std::vector<int> visible_indices_; // Cached for selection mapping
|
||||
|
||||
// Wrapped line height caching (for variable-height text wrapping)
|
||||
mutable std::vector<float> wrapped_heights_; // Height of each visible line (accounts for wrapping)
|
||||
mutable std::vector<float> cumulative_y_offsets_; // Cumulative Y offset for each visible line
|
||||
mutable float total_wrapped_height_ = 0.0f; // Total height of all visible lines
|
||||
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
|
||||
|
||||
|
||||
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
|
||||
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
|
||||
// consumed by the renderer + hit-testing.
|
||||
mutable ConsoleLayout layout_;
|
||||
|
||||
// Commands popup
|
||||
bool show_commands_popup_ = false;
|
||||
};
|
||||
|
||||
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
|
||||
72
src/ui/windows/console_text_layout.h
Normal file
72
src/ui/windows/console_text_layout.h
Normal 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
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "ui/windows/console_input_model.h"
|
||||
#include "ui/windows/console_output_model.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_pool_panel.h"
|
||||
#include "ui/windows/mining_tab_helpers.h"
|
||||
@@ -2282,6 +2283,59 @@ void testDaemonLifecycleAdapters()
|
||||
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()
|
||||
{
|
||||
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
|
||||
@@ -4959,6 +5013,7 @@ int main()
|
||||
testDaemonShutdownPolicy();
|
||||
testDaemonLifecycleExecution();
|
||||
testDaemonLifecycleAdapters();
|
||||
testConsoleTextLayout();
|
||||
testRendererHelpers();
|
||||
testConsoleInputModel();
|
||||
testMiningBenchmarkModel();
|
||||
|
||||
Reference in New Issue
Block a user