From 19e83eb12aa6552d581b11ae4f61d62bac49dcc1 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 17:58:29 -0500 Subject: [PATCH] =?UTF-8?q?refactor(console):=20phase=201=20=E2=80=94=20ex?= =?UTF-8?q?tract=20pure=20ConsoleTextLayout=20(wrap/hit-test/selection)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 2 + src/ui/windows/console_tab.cpp | 230 ++++++------------------- src/ui/windows/console_tab.h | 25 +-- src/ui/windows/console_text_layout.cpp | 162 +++++++++++++++++ src/ui/windows/console_text_layout.h | 72 ++++++++ tests/test_phase4.cpp | 55 ++++++ 6 files changed, 348 insertions(+), 198 deletions(-) create mode 100644 src/ui/windows/console_text_layout.cpp create mode 100644 src/ui/windows/console_text_layout.h diff --git a/CMakeLists.txt b/CMakeLists.txt index b02fd0a..9e4da12 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index f314eb9..b21051e 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.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 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(cur - textStart); - int byteEnd = static_cast(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(cumulative_y_offsets_.size()) - 1; + if (!layout_.cumulativeY.empty()) { + int lo = 0, hi = static_cast(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(cumulative_y_offsets_.size())) { - ImGui::Dummy(ImVec2(0, cumulative_y_offsets_[first_visible])); + if (first_visible > 0 && first_visible < static_cast(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(cumulative_y_offsets_.size()) && - cumulative_y_offsets_[vi] > visible_bottom) { + if (vi < static_cast(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(std::floor((cumulative_y_offsets_[vi] + seg.yOffset) / line_height + 0.5f)); + int rowIndex = static_cast(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(cumulative_y_offsets_.size())) - ? cumulative_y_offsets_[last_rendered_vi] + wrapped_heights_[last_rendered_vi] + float rendered_height = (last_rendered_vi < static_cast(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(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(visible_indices_.size())) { - visible_line = static_cast(visible_indices_.size()) - 1; - pos.line = visible_indices_[visible_line]; - pos.col = static_cast(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(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(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(lines_.size())) return ""; - - // Build a set of visible line indices for quick lookup - // (Only copy visible lines when filtering is active) - std::unordered_set 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(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(text.size()); - - if (i == start.line) col_start = std::min(start.col, static_cast(text.size())); - if (i == end.line) col_end = std::min(end.col, static_cast(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(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() diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index f4688ef..75f382e 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -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 visible_indices_; // Cached for selection mapping - - // Wrapped line height caching (for variable-height text wrapping) - mutable std::vector wrapped_heights_; // Height of each visible line (accounts for wrapping) - mutable std::vector 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> 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; }; diff --git a/src/ui/windows/console_text_layout.cpp b/src/ui/windows/console_text_layout.cpp new file mode 100644 index 0000000..610f589 --- /dev/null +++ b/src/ui/windows/console_text_layout.cpp @@ -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 + +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(cur - textStart); + int byteEnd = static_cast(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(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(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(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& 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(visibleIndices.size()) < lineCount; + std::vector 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(text.size()); + if (i == start.line) colStart = std::min(start.col, static_cast(text.size())); + if (i == end.line) colEnd = std::min(end.col, static_cast(text.size())); + + if (!firstLine) result += "\n"; + firstLine = false; + + if (colStart < colEnd) result += text.substr(colStart, colEnd - colStart); + } + return result; +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_text_layout.h b/src/ui/windows/console_text_layout.h new file mode 100644 index 0000000..32ddcc3 --- /dev/null +++ b/src/ui/windows/console_text_layout.h @@ -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 +#include +#include + +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> segments; // per visible line + std::vector heights; // per visible line (includes the inter-line gap) + std::vector 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; + +// 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& visibleIndices, + ConsoleTextPos start, ConsoleTextPos end); + +} // namespace ui +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 8c6d04e..7d7f0d8 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(e - b) * charW; + } + const char* wrapPosition(const char* b, const char* e, float w) const override { + int maxChars = static_cast(w / charW); + if (maxChars < 1) maxChars = 1; + return (e - b) <= maxChars ? e : b + maxChars; + } + } stub; + + std::vector 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(lines.size()), get, 50.0f, lineH, gap, stub); + EXPECT_EQ(L.segments.size(), static_cast(3)); + EXPECT_EQ(L.segments[0].size(), static_cast(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(1)); // "abc" fits + EXPECT_EQ(L.segments[1][0].byteEnd, 3); + EXPECT_EQ(L.segments[2].size(), static_cast(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 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{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();