aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-04-12 21:04:15 -0500
commit3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch)
tree4c768be256c40f31794c3311f0a2c22933a6705a /libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp
parent892070f3dec1dc690983fd862880e37e711c2516 (diff)
VFS perf fixes, icon/stylesheet compat, download filename fix
VFS: - Open backing fd at mo2_open time for read-only handles so mo2_read can splice without re-opening the file on every read call. - Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when the game keeps hundreds of BSAs open. - Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing unordered_map writes while multiple tree_mutex readers are active. - max_read=1MB + matching conn->max_read and raised max_readahead/max_write so the kernel merges Wine's small sequential reads into bigger FUSE requests. - Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS latency from game-side work. Proton launch: - Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile stalls on first run. UI / plugin compat: - Clamp IconDelegate's per-icon width to [8, 16] so narrow content columns don't trigger QIcon::pixmap(0,0) returning null and logging "failed to load icon" every repaint. - Pre-create lowercase symlinks in the stylesheet directory so QSS files authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg. - Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA so Qt's built-in PNG reader loads them uniformly. - Add mobase.Version.canonicalString shim for legacy Python plugins that still call the old VersionInfo method on appVersion()'s return. - BSPluginList: compare normalized plugin name lists instead of byte hashes so the post-run case-fix refresh stops spuriously firing the "load order changed" dialog. - BSPluginList disabled by default. Download manager: - Parse CDN URL with QUrl + QUrlQuery and read the filename from the response-content-disposition query param (RFC 6266 quoted / unquoted / ext-value forms), not QFileInfo on the raw URL. - When the API or URL gives us a CDN object key (no archive extension), prefer the actual Content-Disposition header from the live response in metaDataChanged and downloadFinished. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp')
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp871
1 files changed, 871 insertions, 0 deletions
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp
new file mode 100644
index 0000000..b330165
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp
@@ -0,0 +1,871 @@
+#include "PluginListModel.h"
+#include "MOPlugin/Settings.h"
+#include "PluginListDropInfo.h"
+
+#include <QGuiApplication>
+#include <QMimeData>
+
+#include <algorithm>
+#include <iterator>
+#include <utility>
+#include <vector>
+
+namespace BSPluginList
+{
+
+PluginListModel::PluginListModel(TESData::PluginList* plugins) : m_Plugins{plugins} {}
+
+QModelIndex PluginListModel::index(int row, int column,
+ [[maybe_unused]] const QModelIndex& parent) const
+{
+ if ((row < 0) || (row >= rowCount()) || (column < 0) || (column >= columnCount())) {
+ return QModelIndex();
+ }
+ return createIndex(row, column, row);
+}
+
+QModelIndex PluginListModel::parent([[maybe_unused]] const QModelIndex& index) const
+{
+ return QModelIndex();
+}
+
+Qt::ItemFlags PluginListModel::flags(const QModelIndex& index) const
+{
+ const int id = index.row();
+
+ Qt::ItemFlags result = QAbstractItemModel::flags(index);
+
+ if (index.isValid()) {
+ const auto plugin = m_Plugins->getPlugin(id);
+ if (plugin && (!plugin->forceLoaded() && !plugin->forceDisabled())) {
+ if (index.column() == COL_PRIORITY)
+ result |= Qt::ItemIsEditable;
+ result |= Qt::ItemIsUserCheckable;
+ }
+ result |= Qt::ItemIsDragEnabled;
+ result &= ~Qt::ItemIsDropEnabled;
+ } else {
+ result |= Qt::ItemIsDropEnabled;
+ }
+
+ return result;
+}
+
+static QVariantList
+conflictListData(const TESData::PluginList* pluginList, const TESData::FileInfo* plugin,
+ const QSet<int>& (TESData::FileInfo::*getConflicts)() const)
+{
+ if (!plugin || !plugin->enabled()) {
+ return QVariantList();
+ }
+
+ QVariantList list;
+ for (const int otherId : (plugin->*getConflicts)()) {
+ const auto other = pluginList->getPlugin(otherId);
+ if (other && other->enabled()) {
+ list.append(otherId);
+ }
+ }
+ return list;
+}
+
+QVariant PluginListModel::data(const QModelIndex& index, int role) const
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ case Qt::EditRole:
+ return displayData(index);
+ case Qt::CheckStateRole:
+ if (index.column() == 0) {
+ return checkstateData(index);
+ }
+ break;
+ case Qt::ForegroundRole:
+ return foregroundData(index);
+ case Qt::BackgroundRole:
+ return backgroundData(index);
+ case Qt::FontRole:
+ return fontData(index);
+ case Qt::TextAlignmentRole:
+ return alignmentData(index);
+ case Qt::ToolTipRole:
+ return tooltipData(index);
+ case GroupingRole: {
+ const auto id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return plugin ? plugin->group() : QVariant();
+ }
+ case IndexRole:
+ return index.row();
+ case InfoRole: {
+ const auto id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return QVariant::fromValue(plugin);
+ }
+ case ConflictsIconRole:
+ return conflictData(index);
+ case FlagsIconRole:
+ return iconData(index);
+ case OriginRole: {
+ const int id = index.row();
+ return m_Plugins->getOriginName(id);
+ }
+ case OverridingRole: {
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return conflictListData(m_Plugins, plugin, &TESData::FileInfo::getPluginOverriding);
+ }
+ case OverriddenRole: {
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return conflictListData(m_Plugins, plugin, &TESData::FileInfo::getPluginOverridden);
+ }
+ case OverwritingAuxRole: {
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return conflictListData(m_Plugins, plugin,
+ &TESData::FileInfo::getPluginOverwritingArchive);
+ }
+ case OverwrittenAuxRole: {
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return conflictListData(m_Plugins, plugin,
+ &TESData::FileInfo::getPluginOverwrittenArchive);
+ }
+ }
+ return QVariant();
+}
+
+QVariant PluginListModel::displayData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+
+ if (!plugin) {
+ return QVariant();
+ }
+
+ switch (index.column()) {
+ case COL_NAME:
+ return plugin->name();
+ case COL_PRIORITY:
+ return plugin->priority();
+ case COL_MODINDEX:
+ return plugin->index();
+ case COL_FORMVERSION:
+ return plugin->formVersion() != 0 ? QString::number(plugin->formVersion())
+ : QString();
+ case COL_HEADERVERSION:
+ return QString::number(plugin->headerVersion());
+ case COL_AUTHOR:
+ return plugin->author();
+ case COL_DESCRIPTION:
+ return plugin->description();
+ default:
+ return QVariant();
+ }
+}
+
+QVariant PluginListModel::checkstateData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+
+ if (!plugin) {
+ return QVariant();
+ }
+
+ if (plugin->isAlwaysEnabled()) {
+ // HACK: PluginListStyledItemDelegate draws the checkbox separately
+ return QVariant();
+ } else if (plugin->forceDisabled()) {
+ return QVariant();
+ } else {
+ return plugin->enabled() ? Qt::Checked : Qt::Unchecked;
+ }
+}
+
+QVariant PluginListModel::foregroundData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+
+ if (!plugin) {
+ return QVariant();
+ }
+
+ if (plugin->hasNoRecords()) {
+ if (index.column() == COL_NAME) {
+ return QBrush(Qt::gray);
+ }
+ }
+
+ if (plugin->forceDisabled()) {
+ if (index.column() == COL_NAME) {
+ return QBrush(Qt::darkRed);
+ }
+ }
+
+ return QVariant();
+}
+
+QVariant
+PluginListModel::backgroundData([[maybe_unused]] const QModelIndex& index) const
+{
+ return QVariant();
+}
+
+QVariant PluginListModel::fontData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+
+ QFont result;
+
+ if (index.column() == COL_NAME) {
+ if (plugin && plugin->hasNoRecords()) {
+ result.setItalic(true);
+ }
+ }
+
+ return result;
+}
+
+QVariant PluginListModel::alignmentData(const QModelIndex& index) const
+{
+ if (index.column() == 0) {
+ return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
+ } else {
+ return QVariant(Qt::AlignHCenter | Qt::AlignVCenter);
+ }
+}
+
+static QString truncateString(const QString& text, int length = 1024)
+{
+ QString new_text = text;
+
+ if (new_text.length() > length) {
+ new_text.truncate(length);
+ new_text += "...";
+ }
+
+ return new_text;
+}
+
+static QString makeLootTooltip(const MOTools::Loot::Plugin&)
+{
+ // LOOT integration removed for Fluorine port — tooltip never built.
+ return {};
+}
+
+QVariant PluginListModel::tooltipData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ const auto lootInfo = m_Plugins->getLootReport(plugin->name());
+
+ if (!plugin) {
+ return QVariant();
+ }
+
+ switch (index.column()) {
+ case COL_NAME: {
+ QString toolTip;
+
+ toolTip += "<b>" + tr("Origin") + "</b>: " + m_Plugins->getOriginName(id);
+
+ if (plugin->forceLoaded()) {
+ toolTip += "<br><b><i>" +
+ tr("This plugin can't be disabled or moved (enforced by the game).") +
+ "</i></b>";
+ } else if (plugin->forceEnabled()) {
+ toolTip += "<br><b><i>" +
+ tr("This plugin can't be disabled (enforced by the game).") +
+ "</i></b>";
+ }
+
+ if (plugin->formVersion() != 0) {
+ // Oblivion-style plugin headers don't have a form version
+ toolTip += "<br><b>" + tr("Form Version") +
+ "</b>: " + QString::number(plugin->formVersion());
+ }
+
+ toolTip += "<br><b>" + tr("Header Version") +
+ "</b>: " + QString::number(plugin->headerVersion());
+
+ if (!plugin->author().isEmpty()) {
+ toolTip += "<br><b>" + tr("Author") + "</b>: " + truncateString(plugin->author());
+ }
+
+ if (plugin->description().size() > 0) {
+ toolTip += "<br><b>" + tr("Description") +
+ "</b>: " + truncateString(plugin->description());
+ }
+
+ if (plugin->enabled() && plugin->missingMasters().size() > 0) {
+ toolTip += "<br><b>" + tr("Missing Masters") + "</b>: " + "<b>" +
+ truncateString(QStringList(plugin->missingMasters().begin(),
+ plugin->missingMasters().end())
+ .join(", ")) +
+ "</b>";
+ }
+
+ QStringList enabledMasters;
+ std::ranges::remove_copy_if(plugin->masters(), std::back_inserter(enabledMasters),
+ [&](auto&& master) {
+ return plugin->missingMasters().contains(master);
+ });
+
+ if (!enabledMasters.empty()) {
+ toolTip += "<br><b>" + tr("Enabled Masters") +
+ "</b>: " + truncateString(enabledMasters.join(", "));
+ }
+
+ if (!plugin->archives().empty()) {
+ QString archiveString =
+ plugin->archives().size() < 6
+ ? truncateString(
+ QStringList(plugin->archives().begin(), plugin->archives().end())
+ .join(", "))
+ : "";
+ toolTip += "<br><b>" + tr("Loads Archives") + "</b>: " + archiveString;
+ }
+
+ if (plugin->hasIni()) {
+ toolTip += "<br><b>" + tr("Loads INI settings") +
+ "</b>: " + QFileInfo(plugin->name()).baseName() + ".ini";
+ }
+
+ if (plugin->hasNoRecords()) {
+ toolTip +=
+ "<br><br>" + tr("This is a dummy plugin. It contains no records and is "
+ "typically used to load a paired archive file.");
+ }
+
+ return toolTip;
+ }
+ case COL_CONFLICTS: {
+ const uint conflictFlags = data(index, ConflictsIconRole).toUInt();
+ using enum TESData::FileInfo::EConflictFlag;
+
+ QString toolTip;
+ if ((conflictFlags & CONFLICT_MIXED) == CONFLICT_MIXED) {
+ toolTip += tr("Overrides & has overridden records");
+ } else if (conflictFlags & CONFLICT_OVERRIDE) {
+ toolTip += tr("Overrides records");
+ } else if (conflictFlags & CONFLICT_OVERRIDDEN) {
+ toolTip += tr("Has overridden records");
+ }
+
+ if ((conflictFlags & CONFLICT_MIXED) && (conflictFlags & CONFLICT_ARCHIVE_MIXED)) {
+ toolTip += "<br>";
+ }
+
+ if ((conflictFlags & CONFLICT_ARCHIVE_MIXED) == CONFLICT_ARCHIVE_MIXED) {
+ toolTip += tr("Overwrites & has overwritten archive files");
+ } else if (conflictFlags & CONFLICT_ARCHIVE_OVERWRITE) {
+ toolTip += tr("Overwrites another archive file");
+ } else if (conflictFlags & CONFLICT_ARCHIVE_OVERWRITTEN) {
+ toolTip += tr("Overwritten by another archive file");
+ }
+ return toolTip;
+ }
+ case COL_FLAGS: {
+ // HACK: insert some HTML to enable multiline tooltips
+ QString toolTip = "<nobr/>";
+ const QString spacing = "<br><br>";
+
+ if (plugin->enabled() && plugin->missingMasters().size() > 0) {
+ toolTip += "<b>" + tr("Missing Masters") + "</b>: " + "<b>" +
+ truncateString(QStringList(plugin->missingMasters().begin(),
+ plugin->missingMasters().end())
+ .join(", ")) +
+ "</b>" + spacing;
+ }
+
+ if (plugin->hasIni()) {
+ toolTip +=
+ tr("There is an ini file connected to this plugin. Its settings will "
+ "be added to your game settings, overwriting in case of conflicts.") +
+ "<br><br>";
+ }
+
+ if (!plugin->archives().empty()) {
+ toolTip +=
+ tr("There are Archives connected to this plugin. Their assets will be "
+ "added to your game, overwriting in case of conflicts following the "
+ "plugin order. Loose files will always overwrite assets from "
+ "Archives.") +
+ spacing;
+ }
+
+ if (plugin->isMasterFile()) {
+ toolTip += tr("This file is flagged as a master plugin (ESM). It will load "
+ "before any non-ESM "
+ "files in the load order.") +
+ spacing;
+ }
+
+ if (plugin->isSmallFile()) {
+ toolTip +=
+ tr("This file is flagged as a light plugin (ESL). It will adhere to its "
+ "position in "
+ "the load order but the records will be loaded in ESL space (FE/FF). You "
+ "can have up to 4096 light plugins in addition to other plugin types.") +
+ spacing;
+ } else if (plugin->isMediumFile()) {
+ toolTip += tr("This file is flagged as a medium plugin (ESH). It will adhere to "
+ "its position in the load order but the records will be loaded in "
+ "ESH space (FD). You can have 256 medium plugins in addition to "
+ "other plugin types.") +
+ spacing;
+ }
+
+ if (plugin->isBlueprintFile()) {
+ toolTip += tr("This plugin has the blueprint flag. This forces it to load after "
+ "every other non-blueprint plugin. Blueprint plugins will adhere "
+ "to standard load order rules with other blueprint plugins.") +
+ spacing;
+ }
+
+ if (plugin->isLightFlagged() && plugin->isMediumFlagged()) {
+ toolTip += tr("WARNING: This plugin is both light and medium flagged. This could "
+ "indicate that the file was saved improperly and may have "
+ "mismatched record references. Use it at your own risk.") +
+ spacing;
+ }
+
+ if (plugin->forceDisabled()) {
+ toolTip += tr("This game does not currently permit custom plugin "
+ "loading. There may be manual workarounds.");
+ }
+
+ if (toolTip.endsWith(spacing)) {
+ toolTip.chop(spacing.length());
+ }
+
+ if (lootInfo) {
+ const auto lootToolTip = makeLootTooltip(*lootInfo);
+ if (toolTip.length() > 7 && !lootToolTip.isEmpty()) {
+ toolTip += "<hr>";
+ }
+ toolTip += lootToolTip;
+ }
+
+ return toolTip;
+ }
+ default:
+ return QVariant();
+ }
+}
+
+QVariant PluginListModel::conflictData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ return plugin->conflictState();
+}
+
+static bool isProblematic(const TESData::FileInfo* plugin,
+ const MOTools::Loot::Plugin* lootInfo)
+{
+ if (plugin && plugin->enabled() && plugin->hasMissingMasters()) {
+ return true;
+ }
+
+ if (lootInfo && Settings::instance()->lootShowProblems()) {
+ if (!lootInfo->incompatibilities.empty()) {
+ return true;
+ }
+
+ if (!lootInfo->missingMasters.empty()) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+QVariant PluginListModel::iconData(const QModelIndex& index) const
+{
+ const int id = index.row();
+ const auto plugin = m_Plugins->getPlugin(id);
+ const auto lootInfo = m_Plugins->getLootReport(plugin->name());
+
+ if (!plugin) {
+ return QVariant();
+ }
+
+ using enum TESData::FileInfo::EFlag;
+ uint flag = 0;
+
+ if (isProblematic(plugin, lootInfo)) {
+ flag |= FLAG_PROBLEMATIC;
+ }
+
+ if (lootInfo && !lootInfo->messages.empty() &&
+ Settings::instance()->lootShowMessages()) {
+ flag |= FLAG_INFORMATION;
+ }
+
+ if (plugin->hasIni()) {
+ flag |= FLAG_INI;
+ }
+
+ if (!plugin->archives().empty()) {
+ flag |= FLAG_BSA;
+ }
+
+ if (plugin->isMasterFile()) {
+ flag |= FLAG_MASTER;
+ }
+
+ if (plugin->isMediumFile()) {
+ flag |= FLAG_MEDIUM;
+ }
+
+ if (plugin->isSmallFile()) {
+ flag |= FLAG_LIGHT;
+ }
+
+ if (plugin->isBlueprintFile()) {
+ flag |= FLAG_BLUEPRINT;
+ }
+
+ if (lootInfo && !lootInfo->dirty.empty() && Settings::instance()->lootShowDirty()) {
+ flag |= FLAG_CLEAN;
+ }
+
+ return flag;
+}
+
+QVariant PluginListModel::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ if (orientation == Qt::Horizontal) {
+ if (role == Qt::DisplayRole) {
+ switch (section) {
+ case COL_NAME:
+ return tr("Name");
+ case COL_CONFLICTS:
+ return tr("Conflicts");
+ case COL_FLAGS:
+ return tr("Flags");
+ case COL_PRIORITY:
+ return tr("Priority");
+ case COL_MODINDEX:
+ return tr("Mod Index");
+ case COL_FORMVERSION:
+ return tr("Form Version");
+ case COL_HEADERVERSION:
+ return tr("Header Version");
+ case COL_AUTHOR:
+ return tr("Author");
+ case COL_DESCRIPTION:
+ return tr("Description");
+ default:
+ return tr("unknown");
+ }
+ }
+ }
+ return QAbstractItemModel::headerData(section, orientation, role);
+}
+
+int PluginListModel::rowCount([[maybe_unused]] const QModelIndex& parent) const
+{
+ return m_Plugins->pluginCount();
+}
+
+int PluginListModel::columnCount([[maybe_unused]] const QModelIndex& parent) const
+{
+ return COL_COUNT;
+}
+
+bool PluginListModel::setData(const QModelIndex& index, const QVariant& value, int role)
+{
+ if (role == Qt::CheckStateRole) {
+ const int id = index.row();
+ m_Plugins->setEnabled(id, value.toInt() == Qt::Checked);
+ emit dataChanged(this->index(0, 0), this->index(rowCount() - 1, COL_MODINDEX),
+ {Qt::EditRole, Qt::CheckStateRole});
+ emit pluginStatesChanged({index});
+ return true;
+ } else if (role == Qt::EditRole) {
+ if (index.column() == COL_PRIORITY) {
+ bool ok;
+ const int newPriority = value.toInt(&ok);
+ if (ok) {
+ int destination = newPriority;
+ if (newPriority > index.data(Qt::EditRole).toInt()) {
+ ++destination;
+ }
+ m_Plugins->moveToPriority({index.row()}, destination);
+ emit dataChanged(this->index(0, 0),
+ this->index(rowCount() - 1, columnCount() - 1),
+ {Qt::EditRole, GroupingRole});
+ emit pluginOrderChanged();
+ return true;
+ }
+ }
+ }
+
+ return false;
+}
+
+Qt::DropActions PluginListModel::supportedDropActions() const
+{
+ return Qt::MoveAction;
+}
+
+bool PluginListModel::canDropMimeData(const QMimeData* data, Qt::DropAction action,
+ int row, [[maybe_unused]] int column,
+ const QModelIndex& parent) const
+{
+ if (action == Qt::IgnoreAction) {
+ return true;
+ }
+
+ if (action != Qt::MoveAction) {
+ return false;
+ }
+
+ PluginListDropInfo dropInfo{data, row, parent, m_Plugins};
+ return m_Plugins->canMoveToPriority(dropInfo.sourceRows(), dropInfo.destination());
+}
+
+bool PluginListModel::dropMimeData(const QMimeData* data, Qt::DropAction action,
+ int row, [[maybe_unused]] int column,
+ const QModelIndex& parent)
+{
+ if (action == Qt::IgnoreAction) {
+ return true;
+ }
+
+ if (action != Qt::MoveAction) {
+ return false;
+ }
+
+ PluginListDropInfo dropInfo{data, row, parent, m_Plugins};
+ m_Plugins->moveToPriority(dropInfo.sourceRows(), dropInfo.destination());
+ emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
+ {Qt::DisplayRole, GroupingRole});
+ emit pluginOrderChanged();
+
+ return true;
+}
+
+QStringList
+PluginListModel::groups(std::function<bool(const TESData::FileInfo*)> pred) const
+{
+ boost::container::flat_set<QString> groupSet;
+ QStringList groups;
+ QString lastGroup;
+
+ for (int priority = 0, count = m_Plugins->pluginCount(); priority < count;
+ ++priority) {
+ const auto plugin = m_Plugins->getPluginByPriority(priority);
+
+ if (pred && !pred(plugin)) {
+ continue;
+ }
+
+ const auto& group = plugin ? plugin->group() : QString();
+ if (group.isEmpty() || group == lastGroup) {
+ continue;
+ }
+
+ auto [it, inserted] = groupSet.insert(group);
+ if (inserted) {
+ groups.append(group);
+ }
+ lastGroup = group;
+ }
+
+ return groups;
+}
+
+QStringList PluginListModel::masterGroups() const
+{
+ return groups([](auto&& plugin) {
+ return !plugin->forceLoaded() && plugin->isMasterFile();
+ });
+}
+
+QStringList PluginListModel::regularGroups() const
+{
+ return groups([](auto&& plugin) {
+ return !plugin->forceLoaded() && !plugin->isMasterFile();
+ });
+}
+
+void PluginListModel::refresh()
+{
+ emit beginResetModel();
+ m_Plugins->refresh();
+ emit endResetModel();
+}
+
+void PluginListModel::invalidate()
+{
+ emit beginResetModel();
+ m_Plugins->refresh(true);
+ emit endResetModel();
+}
+
+void PluginListModel::invalidateConflicts()
+{
+ for (int i = 0, count = m_Plugins->pluginCount(); i < count; ++i) {
+ const auto plugin = m_Plugins->getPlugin(i);
+ plugin->invalidateConflicts();
+ }
+
+ emit dataChanged(index(0, COL_CONFLICTS), index(rowCount() - 1, COL_CONFLICTS),
+ {PluginListModel::ConflictsIconRole});
+}
+
+void PluginListModel::movePlugin(const QString& name, [[maybe_unused]] int oldPriority,
+ int newPriority)
+{
+ m_Plugins->setPriority(name, newPriority);
+ emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
+ {Qt::DisplayRole, GroupingRole});
+ emit pluginOrderChanged();
+}
+
+void PluginListModel::changePluginStates(
+ const std::map<QString, MOBase::IPluginList::PluginStates>& infos)
+{
+ QModelIndexList indices;
+ for (auto& [name, state] : infos) {
+ m_Plugins->setState(name, state);
+
+ const auto idx = m_Plugins->getIndex(name);
+ if (idx != -1) {
+ indices.append(index(idx, 0));
+ }
+ }
+
+ emit dataChanged(index(0, 0), index(rowCount() - 1, COL_MODINDEX),
+ {Qt::DisplayRole, Qt::CheckStateRole});
+ emit pluginStatesChanged(indices);
+}
+
+void PluginListModel::setEnabledAll(bool enabled)
+{
+ QModelIndexList indices;
+ indices.reserve(rowCount());
+ std::generate_n(std::back_inserter(indices), rowCount(), [this, i = 0]() mutable {
+ return index(i++, 0);
+ });
+ setEnabled(indices, enabled);
+}
+
+void PluginListModel::setEnabled(const QModelIndexList& indices, bool enabled)
+{
+ if (indices.empty()) {
+ return;
+ }
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->setEnabled(std::move(ids), enabled);
+ emit pluginStatesChanged(indices);
+}
+
+void PluginListModel::sendToPriority(const QModelIndexList& indices, int priority,
+ bool disjoint)
+{
+ if (indices.empty()) {
+ return;
+ }
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->moveToPriority(std::move(ids), priority, disjoint);
+ emit dataChanged(index(0, COL_PRIORITY), index(rowCount() - 1, COL_MODINDEX),
+ {Qt::DisplayRole});
+ emit pluginOrderChanged();
+}
+
+void PluginListModel::shiftPluginsPriority(const QModelIndexList& indices, int offset)
+{
+ if (indices.empty()) {
+ return;
+ }
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->shiftPriority(std::move(ids), offset);
+ emit dataChanged(index(0, COL_PRIORITY), index(rowCount() - 1, COL_MODINDEX),
+ {Qt::DisplayRole});
+ emit pluginOrderChanged();
+}
+
+void PluginListModel::toggleState(const QModelIndexList& indices)
+{
+ if (indices.empty()) {
+ return;
+ }
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->toggleState(std::move(ids));
+ emit pluginStatesChanged(indices);
+}
+
+void PluginListModel::setGroup(const QModelIndexList& indices, const QString& group)
+{
+ if (indices.empty()) {
+ return;
+ }
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->setGroup(std::move(ids), group);
+ emit dataChanged(this->index(0, 0), this->index(rowCount() - 1, COL_MODINDEX),
+ {GroupingRole});
+}
+
+void PluginListModel::sendToGroup(const QModelIndexList& indices, const QString& group,
+ bool isESM)
+{
+ int destination = -1;
+ for (int priority = 0, count = m_Plugins->pluginCount(); priority < count;
+ ++priority) {
+ const auto plugin = m_Plugins->getPluginByPriority(priority);
+ if (plugin && plugin->isMasterFile() == isESM && plugin->group() == group) {
+ destination = priority + 1;
+ }
+ }
+
+ if (destination == -1)
+ return;
+
+ std::vector<int> ids;
+ ids.reserve(indices.size());
+ std::ranges::transform(indices, std::back_inserter(ids), [](const QModelIndex& idx) {
+ return idx.row();
+ });
+ m_Plugins->setGroup(ids, group);
+ m_Plugins->moveToPriority(std::move(ids), destination);
+ emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1),
+ {Qt::DisplayRole, GroupingRole});
+ emit pluginOrderChanged();
+}
+
+} // namespace BSPluginList