Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 29 additions & 13 deletions src/plugins/score-plugin-clap/Clap/Library.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ void LibraryHandler::setup(

auto& plug = ctx.guiApplicationPlugin<Clap::ApplicationPlugin>();

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<QString, Library::ProcessNode*> categories;

for(const auto& plugin : plug.plugins())
Expand All @@ -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;
}

Expand All @@ -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<QString>& features) const
Expand Down
7 changes: 3 additions & 4 deletions src/plugins/score-plugin-library/Library/PresetItemModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
127 changes: 127 additions & 0 deletions src/plugins/score-plugin-library/Library/ProcessesItemModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include <QElapsedTimer>
#include <QIcon>
#include <QMimeData>
#include <QThreadPool>
#include <QTimer>

namespace Library
Expand All @@ -35,15 +36,121 @@ ProcessesItemModel::ProcessesItemModel(
const score::GUIApplicationContext& ctx, QObject* parent)
: TreeNodeBasedItemModel<ProcessNode>{parent}
, context{ctx}
, m_alive{std::make_shared<std::atomic_bool>(true)}
{
auto& procs = ctx.interfaces<Process::ProcessFactoryList>();
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<Library::Settings::Model>();
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<std::vector<Item>>();
auto& factories = context.interfaces<Process::ProcessFactoryList>();
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<std::pair<ProcessNode*, QString>> 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("/");
Expand Down Expand Up @@ -148,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<ProcessNode*>(&n));
}

QModelIndex ProcessesItemModel::find(const Process::ProcessModelFactory::ConcreteKey& k)
{
for(auto& cat : m_root)
Expand Down
28 changes: 28 additions & 0 deletions src/plugins/score-plugin-library/Library/ProcessesItemModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

#include <QDir>
#include <QIcon>
#include <QTimer>

#include <atomic>
#include <memory>

#include <nano_observer.hpp>
#include <score_plugin_library_export.h>
Expand All @@ -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<ProcessData>;
Expand All @@ -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);
Expand All @@ -77,8 +88,25 @@ 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.
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<std::atomic_bool> m_alive;
};

/** Utility class to organize a library in subcategories that depend
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ bool ProcessFilterProxy::filterAcceptsRowItself(
auto model = static_cast<ProcessesItemModel*>(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);
}
}
15 changes: 13 additions & 2 deletions src/plugins/score-plugin-lv2/LV2/Library.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -58,18 +58,29 @@ class LibraryHandler final : public Library::LibraryInterface

auto& parent = *reinterpret_cast<Library::ProcessNode*>(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();
}
});
});
}
Expand Down
Loading