From dca2ffdd5b5821a311a146d980fe29d43e573c51 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Mon, 22 Jun 2026 17:15:12 -0400 Subject: [PATCH 1/2] library: search processes by tags, not just name The process library filter only matched against the displayed name. Per review, descriptor() must never be called on the search path: it can be extremely slow (it may parse the file behind the node, e.g. ISF/Faust) and search is already too slow as-is. Instead, implement the two-level scan suggested in the review: 1. The existing fast scan stays untouched: names appear immediately. 2. A second, deeper scan runs on a worker thread and computes a lower-case '|'-separated searchString (name, description, tags) per node by calling descriptor() there. Results are delivered to the GUI thread in packets of 100, like RecursiveWatch does, to avoid clobbering the main loop. Since some scanners (LV2, RecursiveWatch commits...) add nodes without begin/endInsertRows, there is no reliable "node added" signal: a 3s timer polls for unindexed nodes; the sweep is a cheap in-memory tree walk once everything is indexed. Node pointers handed to the worker are guarded by a generation counter bumped on model reset/row removal. The filter then matches prettyName and searchString purely in-memory. Fixes #1910 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sn5p9zXz8ZvWcC14x8hiWg --- .../Library/ProcessesItemModel.cpp | 107 ++++++++++++++++++ .../Library/ProcessesItemModel.hpp | 24 ++++ .../Library/RecursiveFilterProxy.cpp | 8 +- 3 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp index 7171dcf8b9..9690871a95 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include namespace Library @@ -35,15 +36,121 @@ ProcessesItemModel::ProcessesItemModel( const score::GUIApplicationContext& ctx, QObject* parent) : TreeNodeBasedItemModel{parent} , context{ctx} + , m_alive{std::make_shared(true)} { auto& procs = ctx.interfaces(); procs.added.connect<&ProcessesItemModel::on_newPlugin>(*this); + // Node pointers handed to the search indexing worker are invalidated by + // resets and removals; drop any in-flight result when that happens. + auto invalidate = [this] { ++m_searchIndexGeneration; }; + connect(this, &QAbstractItemModel::modelAboutToBeReset, this, invalidate); + connect(this, &QAbstractItemModel::rowsAboutToBeRemoved, this, invalidate); + + // Some scanners (LV2, RecursiveWatch commits...) add nodes without + // begin/endInsertRows, so there is no reliable "node added" signal: poll + // instead. The sweep is a cheap in-memory tree walk when there is nothing + // new to index. + m_searchIndexTimer.setInterval(3000); + connect(&m_searchIndexTimer, &QTimer::timeout, this, [this] { indexForSearch(); }); + m_searchIndexTimer.start(); + auto& lib = context.settings(); con(lib, &Library::Settings::Model::rescanLibrary, this, &ProcessesItemModel::rescan); rescan(); } +ProcessesItemModel::~ProcessesItemModel() +{ + *m_alive = false; +} + +void ProcessesItemModel::indexForSearch() +{ + if(m_searchIndexRunning) + return; + + struct Item + { + ProcessNode* node{}; + Process::ProcessModelFactory* factory{}; + QString customData; + QString prettyName; + }; + + // GUI thread: collect the nodes which still need their search data, and + // resolve their factory here so that the worker never touches the + // interface list (which can grow when addons are loaded). + auto items = std::make_shared>(); + auto& factories = context.interfaces(); + auto collect = [&](auto&& self, ProcessNode& node) -> void { + if(node.key != Process::ProcessModelFactory::ConcreteKey{} + && node.searchString.isEmpty()) + { + if(auto* f = factories.get(node.key)) + items->push_back({&node, f, node.customData, node.prettyName}); + } + for(auto& child : node) + self(self, child); + }; + collect(collect, m_root); + + if(items->empty()) + return; + + m_searchIndexRunning = true; + const auto generation = m_searchIndexGeneration; + + // Worker thread: descriptor() is potentially very slow (it may parse the + // file behind the node); this is the whole reason this runs off the GUI + // thread. Results are delivered in packets to avoid clobbering the main + // loop with tens of thousands of queued events. + QThreadPool::globalInstance()->start( + [this, items = std::move(items), generation, alive = m_alive] { + constexpr std::size_t packetSize = 100; + std::vector> packet; + packet.reserve(packetSize); + + auto flush = [&] { + if(packet.empty()) + return; + QMetaObject::invokeMethod( + this, [this, generation, packet = std::move(packet)] { + if(generation != m_searchIndexGeneration) + return; + for(const auto& [node, str] : packet) + node->searchString = str; + }, Qt::QueuedConnection); + packet.clear(); + packet.reserve(packetSize); + }; + + for(const Item& item : *items) + { + if(!*alive) + return; + + const auto desc = item.factory->descriptor(item.customData); + QStringList parts{item.prettyName, desc.prettyName, desc.description}; + parts += desc.tags; + parts.removeAll(QString{}); + parts.removeDuplicates(); + + // Never leave the string empty: an empty searchString means + // "not indexed yet" and the node would be rescanned forever. + QString str = parts.isEmpty() ? QStringLiteral("|") : parts.join(u'|').toLower(); + + packet.emplace_back(item.node, std::move(str)); + if(packet.size() >= packetSize) + flush(); + } + flush(); + + QMetaObject::invokeMethod( + this, [this] { m_searchIndexRunning = false; }, Qt::QueuedConnection); + }); +} + ProcessNode& ProcessesItemModel::addCategory(const QString& c) { auto split = c.split("/"); diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp index f206925262..0f1c612b20 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp @@ -16,6 +16,10 @@ #include #include +#include + +#include +#include #include #include @@ -32,6 +36,12 @@ namespace Library struct ProcessData : Process::ProcessData { QIcon icon; + + //! Lower-case, '|'-separated search data (name, description, tags...). + //! Empty until the async deep scan has indexed this node; the search + //! filter then matches against it in-memory, without ever calling + //! ProcessModelFactory::descriptor() on the search path. + QString searchString; }; using ProcessNode = TreeNode; @@ -54,6 +64,7 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel using QAbstractItemModel::endResetModel; ProcessesItemModel(const score::GUIApplicationContext& ctx, QObject* parent); + ~ProcessesItemModel(); void rescan(); QModelIndex find(const Process::ProcessModelFactory::ConcreteKey& k); @@ -77,8 +88,21 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel private: ProcessNode& addCategory(const QString& cat); + + // Second-level async scan: computes ProcessData::searchString (tags, + // description...) on a worker thread, off the fast name-only scan and off + // the search path. Results come back to the GUI thread in packets. + void indexForSearch(); + const score::GUIApplicationContext& context; ProcessNode m_root; + + QTimer m_searchIndexTimer; + //! Bumped whenever ProcessNode pointers may dangle (model reset, row + //! removal); in-flight indexing results from older generations are dropped. + uint64_t m_searchIndexGeneration{}; + bool m_searchIndexRunning{}; + std::shared_ptr m_alive; }; /** Utility class to organize a library in subcategories that depend diff --git a/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp b/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp index 6c8e843b22..9c8c7ee064 100644 --- a/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp +++ b/src/plugins/score-plugin-library/Library/RecursiveFilterProxy.cpp @@ -77,6 +77,12 @@ bool ProcessFilterProxy::filterAcceptsRowItself( auto model = static_cast(sourceModel()); auto& node = model->nodeFromModelIndex(index); - return node.prettyName.contains(m_textPattern, Qt::CaseInsensitive); + if(node.prettyName.contains(m_textPattern, Qt::CaseInsensitive)) + return true; + + // Also match against the search data (tags, description...) computed + // asynchronously by ProcessesItemModel::indexForSearch(). Purely in-memory: + // the search path never calls into the factories. + return node.searchString.contains(m_textPattern, Qt::CaseInsensitive); } } From 0d0581c0276e131d7664e58f43f22b995b8acad6 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Tue, 21 Jul 2026 13:10:20 -0400 Subject: [PATCH 2/2] library: use insert/remove rows in scanners instead of model reset Library scanners were either doing a full beginResetModel/endResetModel or mutating the node tree with no row signals at all (relying on the periodic search-index sweep to notice). A full reset drops the view's selection and expansion state and invalidates every QModelIndex on each scan tick. Convert the scanner mutation paths to proper QAbstractItemModel row operations: - CLAP: pluginsChanged rebuild now removes the old subtree with beginRemoveRows and inserts the new one with beginInsertRows, mirroring the VST3 handler. The subtree is built into a detached node and moved in under a single insert. - LV2: the async scan result is built off to the side and appended under one beginInsertRows instead of emplacing into the live tree silently. - ProcessesItemModel::on_newPlugin: wraps its category/process inserts in begin/endInsertRows; adds a nodeToIndex() helper to locate a node's QModelIndex. - PresetItemModel::savePreset: inserts the new preset row with begin/endInsertRows instead of resetting the whole list. The startup ProcessesItemModel::rescan() full rebuild (view is empty at that point) and the search-proxy filter reset are left as-is. Remaining silent add paths (Subcategories helper, airwindows/deuterium addons) can follow in a later pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01A6Cn95a5xiSP2UVrzBizXV --- .../score-plugin-clap/Clap/Library.cpp | 42 +++++++++++++------ .../Library/PresetItemModel.cpp | 7 ++-- .../Library/ProcessesItemModel.cpp | 20 +++++++++ .../Library/ProcessesItemModel.hpp | 4 ++ src/plugins/score-plugin-lv2/LV2/Library.hpp | 15 ++++++- 5 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/plugins/score-plugin-clap/Clap/Library.cpp b/src/plugins/score-plugin-clap/Clap/Library.cpp index 31bb5b5a24..cb68cc9c68 100644 --- a/src/plugins/score-plugin-clap/Clap/Library.cpp +++ b/src/plugins/score-plugin-clap/Clap/Library.cpp @@ -23,8 +23,8 @@ void LibraryHandler::setup( auto& plug = ctx.guiApplicationPlugin(); - auto reset_plugs = [this, &plug, &parent] { - // Group plugins by category + // Build the plugin subtree into a detached node, grouping by category. + auto build = [this, &plug](Library::ProcessNode& target) { QMap categories; for(const auto& plugin : plug.plugins()) @@ -36,8 +36,8 @@ void LibraryHandler::setup( // Create category if it doesn't exist if(!categories.contains(category)) { - auto& cat_node = parent.emplace_back( - Library::ProcessData{{{}, category, {}}, {}}, &parent); + auto& cat_node = target.emplace_back( + Library::ProcessData{{{}, category, {}}, {}}, &target); categories[category] = &cat_node; } @@ -48,16 +48,32 @@ void LibraryHandler::setup( } }; - reset_plugs(); + // Rebuild the whole subtree using proper row insertion/removal instead of a + // model reset: this keeps the view's selection/expansion state and does not + // invalidate every unrelated QModelIndex. The subtree is built off to the + // side and moved in under a single begin/endInsertRows. + auto rebuild = [&model, node, &parent, build] { + if(parent.childCount() > 0) + { + model.beginRemoveRows(node, 0, parent.childCount() - 1); + parent.resize(0); + model.endRemoveRows(); + } + + Library::ProcessNode built; + build(built); + + if(const int n = built.childCount(); n > 0) + { + model.beginInsertRows(node, 0, n - 1); + built.moveChildren(parent); + model.endInsertRows(); + } + }; + + rebuild(); - // Async rescan: addToLibrary doesn't emit per-row signals, so reset whole subtree. - con(plug, &Clap::ApplicationPlugin::pluginsChanged, this, - [&model, &parent, reset_plugs] { - model.beginResetModel(); - parent.resize(0); - reset_plugs(); - model.endResetModel(); - }); + con(plug, &Clap::ApplicationPlugin::pluginsChanged, this, rebuild); } QString LibraryHandler::getClapCategory(const QList& features) const diff --git a/src/plugins/score-plugin-library/Library/PresetItemModel.cpp b/src/plugins/score-plugin-library/Library/PresetItemModel.cpp index 81eda0255b..f176c3bfc1 100644 --- a/src/plugins/score-plugin-library/Library/PresetItemModel.cpp +++ b/src/plugins/score-plugin-library/Library/PresetItemModel.cpp @@ -206,17 +206,16 @@ bool PresetItemModel::savePreset(const Process::ProcessModel& proc) if(!updatePresetFilename(preset)) return false; - beginResetModel(); - // beginInsertRows(QModelIndex(), presets.size(), presets.size()); auto it = std::lower_bound( presets.begin(), presets.end(), preset, [](const Process::Preset& lhs, const Process::Preset& rhs) { return lhs.key < rhs.key; }); + const int row = std::distance(presets.begin(), it); + beginInsertRows(QModelIndex(), row, row); presets.insert(it, std::move(preset)); - // endInsertRows(); - endResetModel(); + endInsertRows(); return true; } diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp index 9690871a95..9055b14d11 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp @@ -255,20 +255,40 @@ void ProcessesItemModel::on_newPlugin(const score::InterfaceBase& base) if(it != m_root.end()) { auto& cat = *it; + const int row = cat.childCount(); + beginInsertRows(nodeToIndex(cat), row, row); cat.emplace_back( ProcessData{{fact.concreteKey(), fact.prettyName(), {}}, QIcon{}}, &cat); + endInsertRows(); } else { + const int catRow = m_root.childCount(); + beginInsertRows(QModelIndex{}, catRow, catRow); auto& cat = m_root.emplace_back( ProcessData{ {{}, fact.category(), {}}, Process::getCategoryIcon(fact.category())}, &m_root); + endInsertRows(); + + beginInsertRows(nodeToIndex(cat), 0, 0); cat.emplace_back( ProcessData{{fact.concreteKey(), fact.prettyName(), {}}, QIcon{}}, &cat); + endInsertRows(); } } +QModelIndex ProcessesItemModel::nodeToIndex(const ProcessNode& n) const +{ + auto* parent = n.parent(); + if(!parent) + return {}; // root + const int row = parent->indexOfChild(&n); + if(row < 0) + return {}; + return createIndex(row, 0, const_cast(&n)); +} + QModelIndex ProcessesItemModel::find(const Process::ProcessModelFactory::ConcreteKey& k) { for(auto& cat : m_root) diff --git a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp index 0f1c612b20..ba0ae186d7 100644 --- a/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp +++ b/src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp @@ -89,6 +89,10 @@ class SCORE_PLUGIN_LIBRARY_EXPORT ProcessesItemModel private: ProcessNode& addCategory(const QString& cat); + //! QModelIndex pointing at \a n (invalid for the root), so that scanners can + //! wrap their tree mutations in begin/endInsertRows instead of resetting. + QModelIndex nodeToIndex(const ProcessNode& n) const; + // Second-level async scan: computes ProcessData::searchString (tags, // description...) on a worker thread, off the fast name-only scan and off // the search path. Results come back to the GUI thread in packets. diff --git a/src/plugins/score-plugin-lv2/LV2/Library.hpp b/src/plugins/score-plugin-lv2/LV2/Library.hpp index 7c3e9b2465..cd471ac225 100644 --- a/src/plugins/score-plugin-lv2/LV2/Library.hpp +++ b/src/plugins/score-plugin-lv2/LV2/Library.hpp @@ -58,18 +58,29 @@ class LibraryHandler final : public Library::LibraryInterface auto& parent = *reinterpret_cast(node.internalPointer()); + // Build the subtree off to the side, then append it under a single + // begin/endInsertRows instead of mutating the live model silently. + Library::ProcessNode built; for(auto& category : categories) { // Already sorted through the map - auto& cat = parent.emplace_back( + auto& cat = built.emplace_back( Library::ProcessData{Process::ProcessData{{}, category.first, {}}, {}}, - &parent); + &built); for(auto& plug : category.second) { Library::addToLibrary( cat, Library::ProcessData{Process::ProcessData{key, plug, plug}, {}}); } } + + if(const int n = built.childCount(); n > 0) + { + const int base = parent.childCount(); + model->beginInsertRows(node, base, base + n - 1); + built.moveChildren(parent); + model->endInsertRows(); + } }); }); }