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_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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
TextPos pos;
|
if (visible_indices_.empty()) return {0, 0};
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// Walk characters within this segment for accurate positioning
|
ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize());
|
||||||
pos.col = seg.byteEnd; // default: past end of segment
|
ConsoleHit hit = HitTestConsoleLayout(
|
||||||
for (int c = 0; c < segLen; c++) {
|
layout_, static_cast<int>(visible_indices_.size()),
|
||||||
float wCur = font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c + 1).x;
|
[this](int vi) -> const std::string& { return lines_[visible_indices_[vi]].text; },
|
||||||
float wPrev = (c > 0) ? font->CalcTextSizeA(fontSize, FLT_MAX, 0, segStart, segStart + c).x : 0.0f;
|
screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure);
|
||||||
float charMid = (wPrev + wCur) * 0.5f;
|
|
||||||
if (relative_x < charMid) {
|
TextPos pos;
|
||||||
pos.col = seg.byteStart + c;
|
pos.line = visible_indices_[hit.visibleRow];
|
||||||
return pos;
|
pos.col = hit.col;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -147,24 +148,12 @@ private:
|
|||||||
mutable int filter_match_count_ = 0; // lines matching the text filter (for the toolbar)
|
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
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
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_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();
|
||||||
|
|||||||
Reference in New Issue
Block a user