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:
@@ -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_)
|
||||
|
||||
Reference in New Issue
Block a user