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); } }