refactor(console): phase 4 — extract ConsoleSelectionController from the god-class
Pull the text-selection concern out of ConsoleTab into a focused, ImGui-free
ConsoleSelectionController (console_selection_controller.{h,cpp}). It owns the
anchor/caret state and all the fiddly logic around it: the mouse-drag lifecycle
(beginDrag/updateDrag/endDrag), select-all, range ordering, text extraction over
the visible model, and the index shift applied when the buffer cap evicts lines
from the top.
ConsoleTab now delegates: the four scattered selection fields (is_selecting_,
has_selection_, sel_anchor_, sel_end_) and five helper methods (selectionStart/
End, isPosBeforeOrEqual, getSelectedText, clearSelection) collapse to a single
selection_ member plus a thin selectedText() wrapper. screenToTextPos (still
view-side — it needs the frame's layout) now returns the shared ConsoleTextPos,
so ConsoleTab::TextPos is retired.
The byte-offset / eviction math was previously only reachable through the live
ImGui renderer; it now has direct unit coverage (testConsoleSelectionController:
ordering, drag lifecycle, upward drag, select-all, partial/full/no-op eviction
shift, and filtered extraction). Full-node + lite build clean; ctest green;
source hygiene clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
src/ui/windows/console_selection_controller.cpp
Normal file
89
src/ui/windows/console_selection_controller.cpp
Normal file
@@ -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<int>& visibleIndices) const
|
||||
{
|
||||
if (!active_) return "";
|
||||
return ExtractConsoleSelection(lineCount, getLine, visibleIndices,
|
||||
rangeStart(), rangeEnd());
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
65
src/ui/windows/console_selection_controller.h
Normal file
65
src/ui/windows/console_selection_controller.h
Normal file
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
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<int>& 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
|
||||
@@ -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<int>(model_.size()) - 1,
|
||||
static_cast<int>(model_.back().text.size())};
|
||||
has_selection_ = true;
|
||||
}
|
||||
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) {
|
||||
selection_.selectAll(static_cast<int>(model_.size()),
|
||||
static_cast<int>(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<int>(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<int>(model_.size()) - 1,
|
||||
static_cast<int>(model_.back().text.size())};
|
||||
has_selection_ = true;
|
||||
selection_.selectAll(static_cast<int>(model_.size()),
|
||||
static_cast<int>(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<int>(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<int>(dr.popped);
|
||||
sel_end_.line -= static_cast<int>(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<int>(dr.popped));
|
||||
|
||||
// Track new output that arrived while the user is scrolled up (for the indicator).
|
||||
if (!auto_scroll_)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user