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:
2026-07-01 18:46:06 -05:00
parent 779c9682d4
commit 0cdbbd024e
6 changed files with 290 additions and 108 deletions

View File

@@ -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_)