From 1ced49d622f8243da25b4b68a0e6ef89b3dd5aeb Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 10 Oct 2026 09:50:03 -0500 Subject: [PATCH] perf(console): extend the filtered view incrementally With any console filter on, each batch of new output rescanned all N lines to rebuild the visible set. A text filter lowercase-copied the needle and every line to test it: O(total characters) and 2N allocations per batch. When the filter is unchanged and the model only appended or evicted lines since the last pass, the visible indices drop the evicted lines and shift down, and only the new lines are tested. The result equals a full rescan, since line text and channel never change and the filtered view has no folds. A filter change, Clear, or a model window that doesn't follow on from the last one still rescans. Co-Authored-By: Claude Opus 5.5 --- src/ui/windows/console_output_model.cpp | 13 +++++++++++ src/ui/windows/console_output_model.h | 11 +++++++++ src/ui/windows/console_tab.cpp | 23 +++++++++++++++++-- src/ui/windows/console_tab.h | 7 ++++++ tests/test_phase4.cpp | 30 +++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/ui/windows/console_output_model.cpp b/src/ui/windows/console_output_model.cpp index ba2eaaf..bac5550 100644 --- a/src/ui/windows/console_output_model.cpp +++ b/src/ui/windows/console_output_model.cpp @@ -32,5 +32,18 @@ bool consoleLinePassesFilter(const std::string& lineText, return true; } +bool ExtendFlatVisibleIndices(std::vector& visible, std::uint64_t oldFirst, std::uint64_t oldEnd, + std::uint64_t newFirst, int n, const std::function& passes) +{ + const std::uint64_t newEnd = newFirst + static_cast(n < 0 ? 0 : n); + if (newFirst < oldFirst || newFirst > oldEnd || newEnd < oldEnd) return false; + const int shift = static_cast(newFirst - oldFirst); + visible.erase(visible.begin(), std::lower_bound(visible.begin(), visible.end(), shift)); + for (int& idx : visible) idx -= shift; + for (int i = static_cast(oldEnd - newFirst); i < n; ++i) + if (passes(i)) visible.push_back(i); + return true; +} + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_output_model.h b/src/ui/windows/console_output_model.h index 485a93f..121744a 100644 --- a/src/ui/windows/console_output_model.h +++ b/src/ui/windows/console_output_model.h @@ -2,7 +2,10 @@ #include "console_channel.h" +#include +#include #include +#include namespace dragonx { namespace ui { @@ -20,5 +23,13 @@ bool consoleLinePassesFilter(const std::string& lineText, ConsoleChannel channel, const ConsoleOutputFilter& filter); +// Incrementally maintain a FLAT (filtered, fold-free) visible set across model appends/evictions. `visible` holds +// ascending raw indices computed for the absolute window [oldFirst, oldEnd); the model now spans +// [newFirst, newFirst + n). Drops evicted indices, rebases the survivors and appends every new raw index in +// [oldEnd - newFirst, n) for which passes(i) is true. Returns false (visible untouched) when the windows do not +// chain; the caller then rescans. Only valid while the filter is unchanged (line text/channel are immutable). +bool ExtendFlatVisibleIndices(std::vector& visible, std::uint64_t oldFirst, std::uint64_t oldEnd, + std::uint64_t newFirst, int n, const std::function& passes); + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 16f4a3c..b025800 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -948,15 +948,28 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow // fold triangles aren't drawn/clickable while filtering (else clicks would silently mutate the // collapsed flag with no visible effect and the glyph would disagree with the rendered block). folding_active_ = !has_filter; - visible_indices_.clear(); const int n = static_cast(model_.size()); - if (has_filter) { + const std::uint64_t seqFirst = model_.firstSeq(); + const int flatFlags = (outputFilter.daemonMessagesEnabled ? 1 : 0) | (outputFilter.errorsOnly ? 2 : 0) | + (outputFilter.rpcTraceEnabled ? 4 : 0) | (outputFilter.appMessagesEnabled ? 8 : 0); + // Filtered (flat) view with the exact same filter as the last pass and a model that only appended/evicted + // since: drop evicted indices, rebase, test only the new lines. Identical to the full scan below (line text + + // channel are immutable; the flat view ignores folds) without its per-drain O(total chars) lowercase pass. + const bool extended = has_filter && vis_flat_valid_ && vis_flat_flags_ == flatFlags && + vis_flat_filter_ == outputFilter.text && + ExtendFlatVisibleIndices(visible_indices_, vis_flat_first_, vis_flat_end_, seqFirst, n, + [&](int i) { return consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter); }); + if (extended) { + // visible_indices_ updated in place + } else if (has_filter) { + visible_indices_.clear(); // Filtered view is flat — folding is bypassed so every match is reachable. for (int i = 0; i < n; i++) { if (!consoleLinePassesFilter(model_[i].text, model_[i].channel, outputFilter)) continue; visible_indices_.push_back(i); } } else { + visible_indices_.clear(); // No filter — show all lines, but hide the interior of any collapsed JSON block // (its opener stays visible; the opener + foldSpan lines through the closer are skipped). for (int i = 0; i < n; ) { @@ -966,6 +979,11 @@ void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLow else i++; } } + vis_flat_valid_ = has_filter; + vis_flat_flags_ = flatFlags; + vis_flat_filter_ = outputFilter.text; + vis_flat_first_ = seqFirst; + vis_flat_end_ = seqFirst + static_cast(n); filter_match_count_ = hasTextFilter ? static_cast(visible_indices_.size()) : 0; } @@ -2129,6 +2147,7 @@ void ConsoleTab::clear() ++vis_generation_; layout_ = ConsoleLayout{}; // drop the refs into wrap_cache_ BEFORE resetting it (no dangling segments) wrap_cache_.reset(); + vis_flat_valid_ = false; stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index 1c0f84c..c4dbf7d 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -188,6 +188,13 @@ private: bool folding_active_ = true; // fold UI shown only in the unfiltered (foldable) view bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it) std::string filter_lower_; // lowercased filter needle for match highlighting + // Flat (filtered) view bookkeeping for the incremental computeVisibleLines path: the exact filter and the + // absolute model window [vis_flat_first_, vis_flat_end_) the current visible_indices_ were computed for. + bool vis_flat_valid_ = false; + int vis_flat_flags_ = 0; + std::string vis_flat_filter_; + std::uint64_t vis_flat_first_ = 0; + std::uint64_t vis_flat_end_ = 0; // Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame): // computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 4814b37..f2415fc 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -5858,6 +5858,35 @@ void testConsoleWrapCache() } } +// ExtendFlatVisibleIndices (console_output_model.h): the incremental filtered visible set equals a full rescan. +void testConsoleFlatVisibleIndices() +{ + using dragonx::ui::ExtendFlatVisibleIndices; + std::deque mdl; + std::uint64_t first = 0; + auto passes = [&mdl](int i) { return mdl[static_cast(i)].find('x') != std::string::npos; }; + auto rescan = [&]() { + std::vector v; + for (int i = 0; i < static_cast(mdl.size()); ++i) if (passes(i)) v.push_back(i); + return v; + }; + for (int i = 0; i < 20; ++i) mdl.push_back(i % 3 == 0 ? "x" + std::to_string(i) : std::string("y")); + std::vector vis = rescan(); + std::uint64_t oldFirst = first, oldEnd = first + mdl.size(); + for (int k = 0; k < 4; ++k) { mdl.pop_front(); ++first; } // evict 4 (incl. matches 0 and 3) + for (int i = 20; i < 25; ++i) mdl.push_back(i % 2 == 0 ? "x" : "y"); // append 5 + EXPECT_TRUE(ExtendFlatVisibleIndices(vis, oldFirst, oldEnd, first, static_cast(mdl.size()), passes)); + EXPECT_TRUE(vis == rescan()); + const std::vector untouched = vis; // non-chaining window => refused + EXPECT_FALSE(ExtendFlatVisibleIndices(vis, first, first + mdl.size(), first + mdl.size() + 1, 3, passes)); + EXPECT_TRUE(vis == untouched); + oldFirst = first; oldEnd = first + mdl.size(); // clear(): firstSeq jumps to old end + first += mdl.size(); + mdl.clear(); mdl.push_back("x"); mdl.push_back("y"); + EXPECT_TRUE(ExtendFlatVisibleIndices(vis, oldFirst, oldEnd, first, static_cast(mdl.size()), passes)); + EXPECT_TRUE(vis == rescan()); +} + // ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog). void testConsoleScrollController() { @@ -15098,6 +15127,7 @@ int main() testDaemonLifecycleAdapters(); testConsoleTextLayout(); testConsoleWrapCache(); + testConsoleFlatVisibleIndices(); testConsoleModel(); testPortfolioHelpers(); testMarketSeries();