diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b2a2d2..9f5ff9a 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_model.cpp src/ui/windows/console_output_model.cpp + src/ui/windows/console_selection_controller.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp src/ui/windows/settings_window.cpp @@ -585,6 +586,7 @@ set(APP_HEADERS src/ui/windows/console_input_model.h src/ui/windows/console_model.h src/ui/windows/console_output_model.h + src/ui/windows/console_selection_controller.h src/ui/windows/console_tab.h src/ui/windows/console_tab_helpers.h src/ui/windows/settings_window.h @@ -1023,6 +1025,7 @@ if(BUILD_TESTING) src/ui/windows/console_input_model.cpp src/ui/windows/console_model.cpp src/ui/windows/console_output_model.cpp + src/ui/windows/console_selection_controller.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp src/ui/windows/mining_benchmark.cpp diff --git a/src/ui/windows/console_selection_controller.cpp b/src/ui/windows/console_selection_controller.cpp new file mode 100644 index 0000000..99f6200 --- /dev/null +++ b/src/ui/windows/console_selection_controller.cpp @@ -0,0 +1,89 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "console_selection_controller.h" + +namespace dragonx { +namespace ui { + +bool ConsoleSelectionController::isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b) +{ + if (a.line < b.line) return true; + if (a.line > b.line) return false; + return a.col <= b.col; +} + +void ConsoleSelectionController::beginDrag(ConsoleTextPos pos) +{ + anchor_ = pos; + caret_ = pos; + dragging_ = true; + active_ = false; // not a real selection until the caret moves off the anchor +} + +void ConsoleSelectionController::updateDrag(ConsoleTextPos pos) +{ + caret_ = pos; + // Becomes a real selection once the caret leaves the anchor; never downgrades back. + if (caret_.line != anchor_.line || caret_.col != anchor_.col) + active_ = true; +} + +void ConsoleSelectionController::endDrag(ConsoleTextPos pos) +{ + caret_ = pos; + dragging_ = false; +} + +void ConsoleSelectionController::selectAll(int lineCount, int lastLineByteLen) +{ + if (lineCount <= 0) return; + anchor_ = {0, 0}; + caret_ = {lineCount - 1, lastLineByteLen}; + active_ = true; +} + +void ConsoleSelectionController::clear() +{ + active_ = false; + dragging_ = false; + anchor_ = {-1, 0}; + caret_ = {-1, 0}; +} + +ConsoleTextPos ConsoleSelectionController::rangeStart() const +{ + return isBeforeOrEqual(anchor_, caret_) ? anchor_ : caret_; +} + +ConsoleTextPos ConsoleSelectionController::rangeEnd() const +{ + return isBeforeOrEqual(anchor_, caret_) ? caret_ : anchor_; +} + +void ConsoleSelectionController::shiftForEviction(int popped) +{ + if (popped <= 0 || !active_) return; + anchor_.line -= popped; + caret_.line -= popped; + if (anchor_.line < 0 && caret_.line < 0) { + // Entire selection was in the removed range. + active_ = false; + dragging_ = false; + } else { + if (anchor_.line < 0) { anchor_.line = 0; anchor_.col = 0; } + if (caret_.line < 0) { caret_.line = 0; caret_.col = 0; } + } +} + +std::string ConsoleSelectionController::extract(int lineCount, const ConsoleLineAccessor& getLine, + const std::vector& visibleIndices) const +{ + if (!active_) return ""; + return ExtractConsoleSelection(lineCount, getLine, visibleIndices, + rangeStart(), rangeEnd()); +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_selection_controller.h b/src/ui/windows/console_selection_controller.h new file mode 100644 index 0000000..d0513d9 --- /dev/null +++ b/src/ui/windows/console_selection_controller.h @@ -0,0 +1,65 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// ConsoleSelectionController — owns the console's text-selection state (anchor + caret) and +// all the pure logic around it: the mouse-drag lifecycle, select-all, ordering, extraction, +// and the index shift applied when lines are evicted from the top by the buffer cap. +// +// It is deliberately ImGui-free. Screen->text hit-testing stays in the view (it needs the +// current frame's wrapped layout + output origin); the view feeds the resulting +// ConsoleTextPos values in here. That keeps the fiddly byte-offset / eviction math unit +// testable in isolation from the renderer. + +#pragma once + +#include "console_text_layout.h" // ConsoleTextPos, ConsoleLineAccessor, ExtractConsoleSelection + +#include +#include + +namespace dragonx { +namespace ui { + +class ConsoleSelectionController { +public: + // Mouse-drag lifecycle. Positions are raw {line, col}: line = index into the full model, + // col = byte offset within that line. + void beginDrag(ConsoleTextPos pos); // button press + void updateDrag(ConsoleTextPos pos); // movement while held + void endDrag(ConsoleTextPos pos); // release + + // Select the whole buffer, [0,0] .. [lineCount-1, lastLineByteLen]. No-op if empty. + void selectAll(int lineCount, int lastLineByteLen); + + // Drop the selection entirely (also ends any in-progress drag). + void clear(); + + bool active() const { return active_; } // a selection currently exists + bool dragging() const { return dragging_; } // a drag is in progress + + // Ordered bounds, start <= end. Only meaningful while active(). + ConsoleTextPos rangeStart() const; + ConsoleTextPos rangeEnd() const; + + // After the cap evicts `popped` lines from the front, shift the selection up to track + // the same text. Drops the selection if it fell entirely off the top. + void shiftForEviction(int popped); + + // Selected text, or "" when inactive. `visibleIndices` are the raw line indices currently + // shown (the selection spans raw lines but copies only the visible ones). + std::string extract(int lineCount, const ConsoleLineAccessor& getLine, + const std::vector& visibleIndices) const; + + // Position ordering: is a at or before b in reading order? + static bool isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b); + +private: + ConsoleTextPos anchor_{-1, 0}; // where the drag began / the selection origin + ConsoleTextPos caret_{-1, 0}; // current drag position / the selection focus + bool active_ = false; + bool dragging_ = false; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 509f161..5dcd13a 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -456,15 +456,15 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) // Clear button if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { clear(); - clearSelection(); + selection_.clear(); } ImGui::SameLine(); // Copy button — material styled if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { - if (has_selection_) { - std::string selected = getSelectedText(); + if (selection_.active()) { + std::string selected = selectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } @@ -480,7 +480,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) } } if (ImGui::IsItemHovered()) { - material::Tooltip("%s", has_selection_ ? TR("console_copy_selected") : TR("console_copy_all")); + material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all")); } ImGui::SameLine(); @@ -676,50 +676,36 @@ void ConsoleTab::renderOutput() // Mouse press - start selection (use raw IO mouse state) if (mouse_in_output && io.MouseClicked[0]) { - sel_anchor_ = screenToTextPos(mouse_pos); - sel_end_ = sel_anchor_; - is_selecting_ = true; - has_selection_ = false; + selection_.beginDrag(screenToTextPos(mouse_pos)); } - + // Mouse drag - extend selection (continue even if mouse leaves the window) - if (is_selecting_ && io.MouseDown[0]) { - TextPos new_end = screenToTextPos(mouse_pos); - sel_end_ = new_end; - // Consider it a real selection once the position changes - if (sel_end_.line != sel_anchor_.line || sel_end_.col != sel_anchor_.col) { - has_selection_ = true; - } + if (selection_.dragging() && io.MouseDown[0]) { + selection_.updateDrag(screenToTextPos(mouse_pos)); } - + // Mouse release - finalize selection - if (is_selecting_ && io.MouseReleased[0]) { - sel_end_ = screenToTextPos(mouse_pos); - is_selecting_ = false; + if (selection_.dragging() && io.MouseReleased[0]) { + selection_.endDrag(screenToTextPos(mouse_pos)); } - + // Ctrl+C / Ctrl+A - if (mouse_in_output || has_selection_) { + if (mouse_in_output || selection_.active()) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { - std::string selected = getSelectedText(); + std::string selected = selectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } } - if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A)) { - // Select all - if (!model_.empty()) { - sel_anchor_ = {0, 0}; - sel_end_ = {static_cast(model_.size()) - 1, - static_cast(model_.back().text.size())}; - has_selection_ = true; - } + if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) { + selection_.selectAll(static_cast(model_.size()), + static_cast(model_.back().text.size())); } } - + // Get selection bounds (ordered) - TextPos sel_start_pos = selectionStart(); - TextPos sel_end_pos = selectionEnd(); + ConsoleTextPos sel_start_pos = selection_.rangeStart(); + ConsoleTextPos sel_end_pos = selection_.rangeEnd(); // Render lines with selection highlighting. // Each line is split into pre-computed wrap segments rendered individually @@ -811,7 +797,7 @@ void ConsoleTab::renderOutput() // Determine byte-level selection range for this line int selByteStart = 0, selByteEnd = 0; bool lineSelected = false; - if (has_selection_ && i >= sel_start_pos.line && i <= sel_end_pos.line) { + if (selection_.active() && i >= sel_start_pos.line && i <= sel_end_pos.line) { lineSelected = true; selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0; selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col @@ -925,7 +911,7 @@ void ConsoleTab::renderOutput() // Capture the hash/address under the cursor on right-click, for "Copy value". if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - TextPos tp = screenToTextPos(ImGui::GetMousePos()); + ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos()); context_token_.clear(); if (tp.line >= 0 && tp.line < static_cast(model_.size())) context_token_ = extractCopyableToken(model_[tp.line].text, tp.col); @@ -943,25 +929,23 @@ void ConsoleTab::renderOutput() } ImGui::Separator(); } - if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, has_selection_)) { - std::string selected = getSelectedText(); + if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) { + std::string selected = selectedText(); if (!selected.empty()) { ImGui::SetClipboardText(selected.c_str()); } } if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { if (!model_.empty()) { - sel_anchor_ = {0, 0}; - sel_end_ = {static_cast(model_.size()) - 1, - static_cast(model_.back().text.size())}; - has_selection_ = true; + selection_.selectAll(static_cast(model_.size()), + static_cast(model_.back().text.size())); } } ImGui::Separator(); if (ImGui::MenuItem(TR("console_clear_console"))) { // View-only clear (main thread) — drop the visible lines and the selection. model_.clear(); - has_selection_ = false; + selection_.clear(); } ImGui::EndPopup(); } @@ -1008,7 +992,7 @@ void ConsoleTab::renderOutput() } } -ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const +ConsoleTextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const { if (visible_indices_.empty()) return {0, 0}; @@ -1018,47 +1002,18 @@ ConsoleTab::TextPos ConsoleTab::screenToTextPos(ImVec2 screen_pos) const [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure); - TextPos pos; + ConsoleTextPos pos; pos.line = visible_indices_[hit.visibleRow]; pos.col = hit.col; return pos; } -bool ConsoleTab::isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const +std::string ConsoleTab::selectedText() const { - if (a.line < b.line) return true; - if (a.line > b.line) return false; - return a.col <= b.col; -} - -ConsoleTab::TextPos ConsoleTab::selectionStart() const -{ - return isPosBeforeOrEqual(sel_anchor_, sel_end_) ? sel_anchor_ : sel_end_; -} - -ConsoleTab::TextPos ConsoleTab::selectionEnd() const -{ - return isPosBeforeOrEqual(sel_anchor_, sel_end_) ? sel_end_ : sel_anchor_; -} - -std::string ConsoleTab::getSelectedText() const -{ - if (!has_selection_) return ""; - TextPos start = selectionStart(); - TextPos end = selectionEnd(); - return ExtractConsoleSelection( + return selection_.extract( static_cast(model_.size()), [this](int i) -> const std::string& { return model_[i].text; }, - visible_indices_, - {start.line, start.col}, {end.line, end.col}); -} - -void ConsoleTab::clearSelection() -{ - has_selection_ = false; - is_selecting_ = false; - sel_anchor_ = {-1, 0}; - sel_end_ = {-1, 0}; + visible_indices_); } void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) @@ -1165,7 +1120,7 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) if (first == "clear" || first == "cls") { // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history). clear(); - clearSelection(); + selection_.clear(); } else if (first == "help") { exec.printHelp(add); } else if (first == "quit" || first == "exit") { @@ -1428,17 +1383,7 @@ void ConsoleTab::drainModel() // Lines evicted from the front by the cap shift every index down — move the selection // with them so the highlight stays on the text the user selected. - if (dr.popped > 0 && has_selection_) { - sel_anchor_.line -= static_cast(dr.popped); - sel_end_.line -= static_cast(dr.popped); - if (sel_anchor_.line < 0 && sel_end_.line < 0) { - has_selection_ = false; // entire selection was in the removed range - is_selecting_ = false; - } else { - if (sel_anchor_.line < 0) { sel_anchor_.line = 0; sel_anchor_.col = 0; } - if (sel_end_.line < 0) { sel_end_.line = 0; sel_end_.col = 0; } - } - } + selection_.shiftForEviction(static_cast(dr.popped)); // Track new output that arrived while the user is scrolled up (for the indicator). if (!auto_scroll_) diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 9827d82..93d0a4c 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -6,6 +6,7 @@ #include "console_channel.h" #include "console_model.h" +#include "console_selection_controller.h" #include "console_text_layout.h" #include "../layout.h" #include "../../daemon/embedded_daemon.h" @@ -103,20 +104,13 @@ private: void renderInput(ConsoleCommandExecutor& exec); void renderCommandsPopup(); - // Selection helpers - std::string getSelectedText() const; - void clearSelection(); - - // Convert screen position to line/column - struct TextPos { - int line = -1; - int col = 0; - }; - TextPos screenToTextPos(ImVec2 screen_pos) const; - bool isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const; - TextPos selectionStart() const; // Returns the earlier of sel_anchor_ and sel_end_ - TextPos selectionEnd() const; // Returns the later of sel_anchor_ and sel_end_ - + // Map a screen point to a raw {line, col} position via this frame's wrapped layout. + // (View-side because it needs layout_ + output_origin_; feeds the selection controller.) + ConsoleTextPos screenToTextPos(ImVec2 screen_pos) const; + + // The current selection's text (via selection_ over the visible model). "" if inactive. + std::string selectedText() const; + // Thread-safe line store: producers on any thread call model_.ingest(); the main // thread drains it once per frame in render(). The visible deque is main-thread-only. ConsoleModel model_; @@ -128,11 +122,8 @@ private: int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) - // Text selection state - bool is_selecting_ = false; - bool has_selection_ = false; - TextPos sel_anchor_; // Where the mouse was first pressed - TextPos sel_end_; // Where the mouse currently is / was released + // Text-selection state + logic (anchor/caret, ordering, extraction, eviction shift). + ConsoleSelectionController selection_; float output_scroll_y_ = 0.0f; // Track scroll position for selection ImVec2 output_origin_ = {0, 0}; // Top-left of output area float output_line_height_ = 0.0f; // Text line height (for scanline alignment) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index b84cee8..033f002 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_model.h" #include "ui/windows/console_output_model.h" +#include "ui/windows/console_selection_controller.h" #include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_text_layout.h" #include "ui/windows/mining_benchmark.h" @@ -2337,6 +2338,93 @@ void testConsoleTextLayout() EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector{0}, {0, 1}, {1, 3}), std::string("ello")); } +// ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift. +void testConsoleSelectionController() +{ + using dragonx::ui::ConsoleSelectionController; + using dragonx::ui::ConsoleTextPos; + + // Ordering helper. + EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 2}, {1, 5})); + EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 5}, {2, 0})); + EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({3, 4}, {3, 4})); + EXPECT_FALSE(ConsoleSelectionController::isBeforeOrEqual({2, 0}, {1, 9})); + + // Drag lifecycle: press starts a drag but is not yet a real selection. + { + ConsoleSelectionController sel; + sel.beginDrag({2, 3}); + EXPECT_TRUE(sel.dragging()); + EXPECT_FALSE(sel.active()); + sel.updateDrag({2, 3}); // no movement -> still not active + EXPECT_FALSE(sel.active()); + sel.updateDrag({4, 1}); // moved -> real selection + EXPECT_TRUE(sel.active()); + sel.endDrag({4, 1}); + EXPECT_FALSE(sel.dragging()); + EXPECT_TRUE(sel.active()); + // Ordered range is anchor..caret regardless of drag direction. + EXPECT_EQ(sel.rangeStart().line, 2); + EXPECT_EQ(sel.rangeStart().col, 3); + EXPECT_EQ(sel.rangeEnd().line, 4); + EXPECT_EQ(sel.rangeEnd().col, 1); + } + + // Upward drag: caret before anchor -> range is still ordered low..high. + { + ConsoleSelectionController sel; + sel.beginDrag({5, 2}); + sel.updateDrag({1, 0}); + EXPECT_EQ(sel.rangeStart().line, 1); + EXPECT_EQ(sel.rangeEnd().line, 5); + } + + // Select-all + clear. + { + ConsoleSelectionController sel; + sel.selectAll(0, 0); // empty buffer -> no-op + EXPECT_FALSE(sel.active()); + sel.selectAll(3, 7); // lines 0..2, last line 7 bytes + EXPECT_TRUE(sel.active()); + EXPECT_EQ(sel.rangeStart().line, 0); + EXPECT_EQ(sel.rangeStart().col, 0); + EXPECT_EQ(sel.rangeEnd().line, 2); + EXPECT_EQ(sel.rangeEnd().col, 7); + sel.clear(); + EXPECT_FALSE(sel.active()); + EXPECT_FALSE(sel.dragging()); + } + + // Eviction shift: partial (top of selection clamped), full (dropped), and no-op. + { + ConsoleSelectionController sel; + sel.selectAll(10, 4); // [0,0]..[9,4] + sel.shiftForEviction(3); // 3 lines fell off the top + EXPECT_TRUE(sel.active()); + EXPECT_EQ(sel.rangeStart().line, 0); // was line 0 -> clamped to 0 + EXPECT_EQ(sel.rangeStart().col, 0); + EXPECT_EQ(sel.rangeEnd().line, 6); // 9 - 3 + EXPECT_EQ(sel.rangeEnd().col, 4); + sel.shiftForEviction(0); // no-op + EXPECT_EQ(sel.rangeEnd().line, 6); + sel.shiftForEviction(100); // whole selection evicted -> dropped + EXPECT_FALSE(sel.active()); + } + + // extract(): inactive -> "", active -> the joined selected text. + { + std::vector lines = {"hello", "world"}; + auto get = [&lines](int i) -> const std::string& { return lines[i]; }; + ConsoleSelectionController sel; + EXPECT_EQ(sel.extract(2, get, {}), std::string("")); + sel.beginDrag({0, 1}); + sel.updateDrag({1, 3}); + EXPECT_EQ(sel.extract(2, get, {}), std::string("ello\nwor")); + // Filtered so only line 0 is visible -> selection clips to it. + EXPECT_EQ(sel.extract(2, get, std::vector{0}), std::string("ello")); + } +} + // ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap. void testConsoleModel() { @@ -5104,6 +5192,7 @@ int main() testDaemonLifecycleAdapters(); testConsoleTextLayout(); testConsoleModel(); + testConsoleSelectionController(); testRendererHelpers(); testConsoleInputModel(); testMiningBenchmarkModel();