refactor(console): phase 4b — extract ConsoleScrollController

Pull the auto-scroll concern out of ConsoleTab into a focused, ImGui-free
ConsoleScrollController (console_scroll_controller.{h,cpp}). It owns the three
coupled fields that were loose on the god-class — auto_scroll_, the wheel-up
cooldown, and the new-lines-while-scrolled-up backlog count — and the state
machine tying them together: wheel-up detaches + starts a cooldown, the cooldown
gates the at-bottom re-enable check, backlog counting happens only while
detached, and re-pinning (checkbox / jump pill / reaching the bottom) clears it.

The view keeps only what needs ImGui: it measures scroll position, hands the
controller a plain `atBottom` bool + frame delta, and issues the actual
SetScrollHereY. ConsoleTab loses three more members and all the scattered
auto-scroll bookkeeping collapses to scroll_.* calls.

Adds testConsoleScrollController: pinned start ignores backlog, wheel-up detach +
cooldown gating, re-enable only when at bottom, checkbox toggle semantics, and
the jump-to-bottom pill. 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:52:15 -05:00
parent 0cdbbd024e
commit 18c1de87db
6 changed files with 185 additions and 25 deletions

View File

@@ -268,14 +268,12 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
// At the bottom → re-enable; scrolled up → already disabled by wheel handler.
// After wheel-up, wait for the cooldown so smooth-scroll can animate
// away from the bottom before we check position again.
if (scroll_up_cooldown_ > 0.0f)
scroll_up_cooldown_ -= ImGui::GetIO().DeltaTime;
if (!auto_scroll_ && scroll_up_cooldown_ <= 0.0f && consoleScrollMaxY > 0.0f) {
scroll_.tickCooldown(ImGui::GetIO().DeltaTime);
{
float tolerance = Type().caption()->LegacySize * 1.5f;
if (consoleScrollY >= consoleScrollMaxY - tolerance) {
auto_scroll_ = true;
new_lines_since_scroll_ = 0;
}
bool atBottom = consoleScrollMaxY > 0.0f &&
consoleScrollY >= consoleScrollMaxY - tolerance;
scroll_.considerReenable(atBottom);
}
// CSS-style clipping mask
@@ -283,7 +281,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
// inflating scrollMax so the mask thinks there's content below.
{
float fadeZone = std::min(Type().caption()->LegacySize * 3.0f, outputH * 0.18f);
float effectiveScrollMax = auto_scroll_ ? consoleScrollMaxY
float effectiveScrollMax = scroll_.autoScroll() ? consoleScrollMaxY
: std::max(consoleScrollMaxY, consoleScrollY + 10.0f);
ApplyScrollEdgeMask(dlOut, consoleParentVtx, consoleChildDL, consoleChildVtx,
outPanelMin.y, outPanelMax.y, fadeZone, consoleScrollY, effectiveScrollMax);
@@ -397,10 +395,9 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine();
// Auto-scroll toggle
if (ImGui::Checkbox(TR("console_auto_scroll"), &auto_scroll_)) {
if (auto_scroll_) {
new_lines_since_scroll_ = 0;
}
bool autoScroll = scroll_.autoScroll();
if (ImGui::Checkbox(TR("console_auto_scroll"), &autoScroll)) {
scroll_.setAutoScroll(autoScroll);
}
ImGui::SameLine();
@@ -661,8 +658,7 @@ void ConsoleTab::renderOutput()
// Disable auto-scroll when user scrolls up (wheel scroll)
if (mouse_in_output && io.MouseWheel > 0.0f) {
auto_scroll_ = false;
scroll_up_cooldown_ = 0.5f; // give smooth-scroll time to animate away
scroll_.onUserScrolledUp();
}
// Scrolling down to the very bottom re-enables auto-scroll.
// Actual position check happens after EndChild() using captured
@@ -893,9 +889,9 @@ void ConsoleTab::renderOutput()
// Auto-scroll - when enabled, always scroll to bottom of content
// This ensures daemon output stays visible and scrolled to bottom
if (auto_scroll_) {
if (scroll_.autoScroll()) {
ImGui::SetScrollHereY(1.0f);
new_lines_since_scroll_ = 0;
scroll_.resetNewLines();
}
// Filter indicator (text filter only — daemon toggle is already visible in toolbar)
@@ -951,7 +947,7 @@ void ConsoleTab::renderOutput()
}
// "New output" indicator when user is scrolled up and new lines arrived
if (!auto_scroll_ && new_lines_since_scroll_ > 0) {
if (!scroll_.autoScroll() && scroll_.newLines() > 0) {
float indicW = 140.0f;
float indicH = 24.0f;
ImDrawList* dlInd = ImGui::GetWindowDrawList();
@@ -966,7 +962,7 @@ void ConsoleTab::renderOutput()
dlInd->AddRect(iMin, iMax, IM_COL32(255, 218, 0, 120), 12.0f);
char buf[48];
snprintf(buf, sizeof(buf), TR("console_new_lines"), new_lines_since_scroll_);
snprintf(buf, sizeof(buf), TR("console_new_lines"), scroll_.newLines());
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
ImFont* icoFont = Type().iconSmall();
@@ -986,8 +982,7 @@ void ConsoleTab::renderOutput()
// Click to jump to bottom
ImGui::SetCursorScreenPos(iMin);
if (ImGui::InvisibleButton("##scrollToBottom", ImVec2(indicW, indicH))) {
auto_scroll_ = true;
new_lines_since_scroll_ = 0;
scroll_.jumpToBottom();
}
}
}
@@ -1386,8 +1381,7 @@ void ConsoleTab::drainModel()
selection_.shiftForEviction(static_cast<int>(dr.popped));
// Track new output that arrived while the user is scrolled up (for the indicator).
if (!auto_scroll_)
new_lines_since_scroll_ += static_cast<int>(dr.added);
scroll_.onLinesAdded(static_cast<int>(dr.added));
}
void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method)