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

@@ -460,6 +460,7 @@ set(APP_SOURCES
src/ui/windows/console_input_model.cpp src/ui/windows/console_input_model.cpp
src/ui/windows/console_model.cpp src/ui/windows/console_model.cpp
src/ui/windows/console_output_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_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp src/ui/windows/console_text_layout.cpp
src/ui/windows/settings_window.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_input_model.h
src/ui/windows/console_model.h src/ui/windows/console_model.h
src/ui/windows/console_output_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.h
src/ui/windows/console_tab_helpers.h src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.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_input_model.cpp
src/ui/windows/console_model.cpp src/ui/windows/console_model.cpp
src/ui/windows/console_output_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_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp src/ui/windows/console_text_layout.cpp
src/ui/windows/mining_benchmark.cpp src/ui/windows/mining_benchmark.cpp

View 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

View 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

View File

@@ -456,15 +456,15 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
// Clear button // Clear button
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
clear(); clear();
clearSelection(); selection_.clear();
} }
ImGui::SameLine(); ImGui::SameLine();
// Copy button — material styled // Copy button — material styled
if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) { if (TactileButton(TR("copy"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
if (has_selection_) { if (selection_.active()) {
std::string selected = getSelectedText(); std::string selected = selectedText();
if (!selected.empty()) { if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str()); ImGui::SetClipboardText(selected.c_str());
} }
@@ -480,7 +480,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
} }
} }
if (ImGui::IsItemHovered()) { 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(); ImGui::SameLine();
@@ -676,50 +676,36 @@ void ConsoleTab::renderOutput()
// Mouse press - start selection (use raw IO mouse state) // Mouse press - start selection (use raw IO mouse state)
if (mouse_in_output && io.MouseClicked[0]) { if (mouse_in_output && io.MouseClicked[0]) {
sel_anchor_ = screenToTextPos(mouse_pos); selection_.beginDrag(screenToTextPos(mouse_pos));
sel_end_ = sel_anchor_;
is_selecting_ = true;
has_selection_ = false;
} }
// Mouse drag - extend selection (continue even if mouse leaves the window) // Mouse drag - extend selection (continue even if mouse leaves the window)
if (is_selecting_ && io.MouseDown[0]) { if (selection_.dragging() && io.MouseDown[0]) {
TextPos new_end = screenToTextPos(mouse_pos); selection_.updateDrag(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;
}
} }
// Mouse release - finalize selection // Mouse release - finalize selection
if (is_selecting_ && io.MouseReleased[0]) { if (selection_.dragging() && io.MouseReleased[0]) {
sel_end_ = screenToTextPos(mouse_pos); selection_.endDrag(screenToTextPos(mouse_pos));
is_selecting_ = false;
} }
// Ctrl+C / Ctrl+A // Ctrl+C / Ctrl+A
if (mouse_in_output || has_selection_) { if (mouse_in_output || selection_.active()) {
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C)) {
std::string selected = getSelectedText(); std::string selected = selectedText();
if (!selected.empty()) { if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str()); ImGui::SetClipboardText(selected.c_str());
} }
} }
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A)) { if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_A) && !model_.empty()) {
// Select all selection_.selectAll(static_cast<int>(model_.size()),
if (!model_.empty()) { static_cast<int>(model_.back().text.size()));
sel_anchor_ = {0, 0};
sel_end_ = {static_cast<int>(model_.size()) - 1,
static_cast<int>(model_.back().text.size())};
has_selection_ = true;
}
} }
} }
// Get selection bounds (ordered) // Get selection bounds (ordered)
TextPos sel_start_pos = selectionStart(); ConsoleTextPos sel_start_pos = selection_.rangeStart();
TextPos sel_end_pos = selectionEnd(); ConsoleTextPos sel_end_pos = selection_.rangeEnd();
// Render lines with selection highlighting. // Render lines with selection highlighting.
// Each line is split into pre-computed wrap segments rendered individually // 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 // Determine byte-level selection range for this line
int selByteStart = 0, selByteEnd = 0; int selByteStart = 0, selByteEnd = 0;
bool lineSelected = false; 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; lineSelected = true;
selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0; selByteStart = (i == sel_start_pos.line) ? sel_start_pos.col : 0;
selByteEnd = (i == sel_end_pos.line) ? sel_end_pos.col 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". // Capture the hash/address under the cursor on right-click, for "Copy value".
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
TextPos tp = screenToTextPos(ImGui::GetMousePos()); ConsoleTextPos tp = screenToTextPos(ImGui::GetMousePos());
context_token_.clear(); context_token_.clear();
if (tp.line >= 0 && tp.line < static_cast<int>(model_.size())) if (tp.line >= 0 && tp.line < static_cast<int>(model_.size()))
context_token_ = extractCopyableToken(model_[tp.line].text, tp.col); context_token_ = extractCopyableToken(model_[tp.line].text, tp.col);
@@ -943,25 +929,23 @@ void ConsoleTab::renderOutput()
} }
ImGui::Separator(); ImGui::Separator();
} }
if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, has_selection_)) { if (ImGui::MenuItem(TR("copy"), "Ctrl+C", false, selection_.active())) {
std::string selected = getSelectedText(); std::string selected = selectedText();
if (!selected.empty()) { if (!selected.empty()) {
ImGui::SetClipboardText(selected.c_str()); ImGui::SetClipboardText(selected.c_str());
} }
} }
if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) { if (ImGui::MenuItem(TR("console_select_all"), "Ctrl+A")) {
if (!model_.empty()) { if (!model_.empty()) {
sel_anchor_ = {0, 0}; selection_.selectAll(static_cast<int>(model_.size()),
sel_end_ = {static_cast<int>(model_.size()) - 1, static_cast<int>(model_.back().text.size()));
static_cast<int>(model_.back().text.size())};
has_selection_ = true;
} }
} }
ImGui::Separator(); ImGui::Separator();
if (ImGui::MenuItem(TR("console_clear_console"))) { if (ImGui::MenuItem(TR("console_clear_console"))) {
// View-only clear (main thread) — drop the visible lines and the selection. // View-only clear (main thread) — drop the visible lines and the selection.
model_.clear(); model_.clear();
has_selection_ = false; selection_.clear();
} }
ImGui::EndPopup(); 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}; 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; }, [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); screen_pos.x - output_origin_.x, screen_pos.y - output_origin_.y, measure);
TextPos pos; ConsoleTextPos pos;
pos.line = visible_indices_[hit.visibleRow]; pos.line = visible_indices_[hit.visibleRow];
pos.col = hit.col; pos.col = hit.col;
return pos; return pos;
} }
bool ConsoleTab::isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const std::string ConsoleTab::selectedText() const
{ {
if (a.line < b.line) return true; return selection_.extract(
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(
static_cast<int>(model_.size()), static_cast<int>(model_.size()),
[this](int i) -> const std::string& { return model_[i].text; }, [this](int i) -> const std::string& { return model_[i].text; },
visible_indices_, 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};
} }
void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
@@ -1165,7 +1120,7 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
if (first == "clear" || first == "cls") { if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history). // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
clear(); clear();
clearSelection(); selection_.clear();
} else if (first == "help") { } else if (first == "help") {
exec.printHelp(add); exec.printHelp(add);
} else if (first == "quit" || first == "exit") { } 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 // 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. // with them so the highlight stays on the text the user selected.
if (dr.popped > 0 && has_selection_) { selection_.shiftForEviction(static_cast<int>(dr.popped));
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; }
}
}
// Track new output that arrived while the user is scrolled up (for the indicator). // Track new output that arrived while the user is scrolled up (for the indicator).
if (!auto_scroll_) if (!auto_scroll_)

View File

@@ -6,6 +6,7 @@
#include "console_channel.h" #include "console_channel.h"
#include "console_model.h" #include "console_model.h"
#include "console_selection_controller.h"
#include "console_text_layout.h" #include "console_text_layout.h"
#include "../layout.h" #include "../layout.h"
#include "../../daemon/embedded_daemon.h" #include "../../daemon/embedded_daemon.h"
@@ -103,19 +104,12 @@ private:
void renderInput(ConsoleCommandExecutor& exec); void renderInput(ConsoleCommandExecutor& exec);
void renderCommandsPopup(); void renderCommandsPopup();
// Selection helpers // Map a screen point to a raw {line, col} position via this frame's wrapped layout.
std::string getSelectedText() const; // (View-side because it needs layout_ + output_origin_; feeds the selection controller.)
void clearSelection(); ConsoleTextPos screenToTextPos(ImVec2 screen_pos) const;
// Convert screen position to line/column // The current selection's text (via selection_ over the visible model). "" if inactive.
struct TextPos { std::string selectedText() const;
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_
// Thread-safe line store: producers on any thread call model_.ingest(); the main // 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. // thread drains it once per frame in render(). The visible deque is main-thread-only.
@@ -128,11 +122,8 @@ private:
int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator) int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator)
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
// Text selection state // Text-selection state + logic (anchor/caret, ordering, extraction, eviction shift).
bool is_selecting_ = false; ConsoleSelectionController selection_;
bool has_selection_ = false;
TextPos sel_anchor_; // Where the mouse was first pressed
TextPos sel_end_; // Where the mouse currently is / was released
float output_scroll_y_ = 0.0f; // Track scroll position for selection float output_scroll_y_ = 0.0f; // Track scroll position for selection
ImVec2 output_origin_ = {0, 0}; // Top-left of output area ImVec2 output_origin_ = {0, 0}; // Top-left of output area
float output_line_height_ = 0.0f; // Text line height (for scanline alignment) float output_line_height_ = 0.0f; // Text line height (for scanline alignment)

View File

@@ -15,6 +15,7 @@
#include "ui/windows/console_input_model.h" #include "ui/windows/console_input_model.h"
#include "ui/windows/console_model.h" #include "ui/windows/console_model.h"
#include "ui/windows/console_output_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_tab_helpers.h"
#include "ui/windows/console_text_layout.h" #include "ui/windows/console_text_layout.h"
#include "ui/windows/mining_benchmark.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")); 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. // ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap.
void testConsoleModel() void testConsoleModel()
{ {
@@ -5104,6 +5192,7 @@ int main()
testDaemonLifecycleAdapters(); testDaemonLifecycleAdapters();
testConsoleTextLayout(); testConsoleTextLayout();
testConsoleModel(); testConsoleModel();
testConsoleSelectionController();
testRendererHelpers(); testRendererHelpers();
testConsoleInputModel(); testConsoleInputModel();
testMiningBenchmarkModel(); testMiningBenchmarkModel();