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

@@ -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<int>{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<std::string> 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<int>{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();