diff --git a/CMakeLists.txt b/CMakeLists.txt index 9f5ff9a..fa105ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -460,6 +460,7 @@ set(APP_SOURCES src/ui/windows/console_input_model.cpp src/ui/windows/console_model.cpp src/ui/windows/console_output_model.cpp + src/ui/windows/console_scroll_controller.cpp src/ui/windows/console_selection_controller.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp @@ -586,6 +587,7 @@ set(APP_HEADERS src/ui/windows/console_input_model.h src/ui/windows/console_model.h src/ui/windows/console_output_model.h + src/ui/windows/console_scroll_controller.h src/ui/windows/console_selection_controller.h src/ui/windows/console_tab.h src/ui/windows/console_tab_helpers.h @@ -1025,6 +1027,7 @@ if(BUILD_TESTING) src/ui/windows/console_input_model.cpp src/ui/windows/console_model.cpp src/ui/windows/console_output_model.cpp + src/ui/windows/console_scroll_controller.cpp src/ui/windows/console_selection_controller.cpp src/ui/windows/console_tab_helpers.cpp src/ui/windows/console_text_layout.cpp diff --git a/src/ui/windows/console_scroll_controller.cpp b/src/ui/windows/console_scroll_controller.cpp new file mode 100644 index 0000000..2a9d43f --- /dev/null +++ b/src/ui/windows/console_scroll_controller.cpp @@ -0,0 +1,39 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "console_scroll_controller.h" + +namespace dragonx { +namespace ui { + +void ConsoleScrollController::setAutoScroll(bool on) +{ + auto_scroll_ = on; + if (on) new_lines_ = 0; // re-pinning to the bottom clears the backlog counter +} + +void ConsoleScrollController::onUserScrolledUp() +{ + auto_scroll_ = false; + cooldown_ = kScrollUpCooldownSeconds; +} + +void ConsoleScrollController::tickCooldown(float dt) +{ + if (cooldown_ > 0.0f) cooldown_ -= dt; +} + +void ConsoleScrollController::considerReenable(bool atBottom) +{ + if (!auto_scroll_ && cooldown_ <= 0.0f && atBottom) + pinToBottom(); +} + +void ConsoleScrollController::onLinesAdded(int n) +{ + if (!auto_scroll_ && n > 0) new_lines_ += n; +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_scroll_controller.h b/src/ui/windows/console_scroll_controller.h new file mode 100644 index 0000000..e268903 --- /dev/null +++ b/src/ui/windows/console_scroll_controller.h @@ -0,0 +1,63 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// ConsoleScrollController — the console's auto-scroll state machine. +// +// It owns three coupled pieces of state that were previously loose fields on the god-class: +// - auto_scroll_ : whether output is pinned to the bottom (follows new lines) +// - a cooldown : a short grace period after a wheel-up so smooth-scroll can animate away +// from the bottom before the "am I at the bottom?" re-enable check runs +// - new_lines_ : how many lines arrived while scrolled up (drives the jump-to-bottom pill) +// +// It is ImGui-free: the view measures scroll positions and hands in a plain `atBottom` bool +// and a frame delta; the controller decides when to re-pin. That keeps the interplay +// (wheel-up pauses, cooldown gates the re-enable, new-line counting only while scrolled up) +// unit testable. + +#pragma once + +namespace dragonx { +namespace ui { + +class ConsoleScrollController { +public: + // Seconds to ignore the at-bottom re-enable check after a user wheel-up, so an in-flight + // smooth-scroll can move away from the bottom first. + static constexpr float kScrollUpCooldownSeconds = 0.5f; + + bool autoScroll() const { return auto_scroll_; } + int newLines() const { return new_lines_; } + + // User toggled the "auto-scroll" checkbox. Enabling re-pins to the bottom. + void setAutoScroll(bool on); + + // User scrolled up with the wheel — stop following output and start the cooldown. + void onUserScrolledUp(); + + // Advance the cooldown timer by one frame. + void tickCooldown(float dt); + + // The view reports whether the viewport is currently at the bottom. If auto-scroll is off + // and the cooldown has elapsed, reaching the bottom re-enables auto-scroll. + void considerReenable(bool atBottom); + + // `n` new lines were appended to the model this frame; count them only while scrolled up. + void onLinesAdded(int n); + + // Called each frame while pinned to the bottom (the view issues the actual scroll). + void resetNewLines() { new_lines_ = 0; } + + // User clicked the jump-to-bottom pill. + void jumpToBottom() { pinToBottom(); } + +private: + void pinToBottom() { auto_scroll_ = true; new_lines_ = 0; } + + bool auto_scroll_ = true; + float cooldown_ = 0.0f; + int new_lines_ = 0; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 5dcd13a..131d214 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -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(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(dr.added); + scroll_.onLinesAdded(static_cast(dr.added)); } void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& method) diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 93d0a4c..01fbcb1 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -6,6 +6,7 @@ #include "console_channel.h" #include "console_model.h" +#include "console_scroll_controller.h" #include "console_selection_controller.h" #include "console_text_layout.h" #include "../layout.h" @@ -117,11 +118,11 @@ private: std::vector command_history_; int history_index_ = -1; char input_buffer_[4096] = {0}; - bool auto_scroll_ = true; - float scroll_up_cooldown_ = 0.0f; // seconds to wait before re-enabling auto-scroll - int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) + // Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count). + ConsoleScrollController scroll_; + // Text-selection state + logic (anchor/caret, ordering, extraction, eviction shift). ConsoleSelectionController selection_; float output_scroll_y_ = 0.0f; // Track scroll position for selection diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 033f002..77398a9 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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_scroll_controller.h" #include "ui/windows/console_selection_controller.h" #include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_text_layout.h" @@ -2338,6 +2339,64 @@ void testConsoleTextLayout() EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector{0}, {0, 1}, {1, 3}), std::string("ello")); } +// ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog). +void testConsoleScrollController() +{ + using dragonx::ui::ConsoleScrollController; + + // Starts pinned to the bottom; new lines while pinned are not counted as a backlog. + { + ConsoleScrollController s; + EXPECT_TRUE(s.autoScroll()); + s.onLinesAdded(5); + EXPECT_EQ(s.newLines(), 0); + } + + // Wheel-up detaches, starts the cooldown, and begins counting backlog lines. + { + ConsoleScrollController s; + s.onUserScrolledUp(); + EXPECT_FALSE(s.autoScroll()); + s.onLinesAdded(3); + s.onLinesAdded(2); + EXPECT_EQ(s.newLines(), 5); + + // At-bottom is ignored until the cooldown elapses. + s.considerReenable(true); + EXPECT_FALSE(s.autoScroll()); + s.tickCooldown(ConsoleScrollController::kScrollUpCooldownSeconds + 0.01f); + // Elapsed, but only re-pins when actually at the bottom. + s.considerReenable(false); + EXPECT_FALSE(s.autoScroll()); + s.considerReenable(true); + EXPECT_TRUE(s.autoScroll()); + EXPECT_EQ(s.newLines(), 0); // re-pinning clears the backlog + } + + // Checkbox toggle: enabling clears the backlog, disabling preserves state. + { + ConsoleScrollController s; + s.onUserScrolledUp(); + s.onLinesAdded(4); + EXPECT_EQ(s.newLines(), 4); + s.setAutoScroll(true); + EXPECT_TRUE(s.autoScroll()); + EXPECT_EQ(s.newLines(), 0); + s.setAutoScroll(false); + EXPECT_FALSE(s.autoScroll()); + } + + // Jump-to-bottom pill re-pins and clears the backlog. + { + ConsoleScrollController s; + s.onUserScrolledUp(); + s.onLinesAdded(9); + s.jumpToBottom(); + EXPECT_TRUE(s.autoScroll()); + EXPECT_EQ(s.newLines(), 0); + } +} + // ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift. void testConsoleSelectionController() { @@ -5192,6 +5251,7 @@ int main() testDaemonLifecycleAdapters(); testConsoleTextLayout(); testConsoleModel(); + testConsoleScrollController(); testConsoleSelectionController(); testRendererHelpers(); testConsoleInputModel();