aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_bsplugins/src/BSPluginList
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
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')
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp48
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h28
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp67
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h28
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp594
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h118
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp349
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h54
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp42
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h29
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp871
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListModel.h137
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp119
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h31
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp395
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListView.h76
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h29
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp122
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h40
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp833
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h103
-rw-r--r--libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui279
22 files changed, 4392 insertions, 0 deletions
diff --git a/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp
new file mode 100644
index 0000000..a32471a
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.cpp
@@ -0,0 +1,48 @@
+#include "ConflictIconDelegate.h"
+
+namespace BSPluginList
+{
+
+using enum TESData::FileInfo::EConflictFlag;
+
+ConflictIconDelegate::ConflictIconDelegate(PluginListView* view)
+ : GUI::IconDelegate(view), m_View{view}
+{}
+
+QList<QString> ConflictIconDelegate::getIcons(const QModelIndex& index) const
+{
+ const auto flags = m_View->conflictFlags(index);
+
+ QList<QString> icons;
+
+ if ((flags & CONFLICT_MIXED) == CONFLICT_MIXED) {
+ icons.append(":/MO/gui/emblem_conflict_mixed");
+ } else if (flags & CONFLICT_OVERRIDE) {
+ icons.append(":/MO/gui/emblem_conflict_overwrite");
+ } else if (flags & CONFLICT_OVERRIDDEN) {
+ icons.append(":/MO/gui/emblem_conflict_overwritten");
+ }
+
+ if ((flags & CONFLICT_ARCHIVE_MIXED) == CONFLICT_ARCHIVE_MIXED) {
+ icons.append(":/MO/gui/archive_conflict_mixed");
+ } else if (flags & CONFLICT_ARCHIVE_OVERWRITE) {
+ icons.append(":/MO/gui/archive_conflict_winner");
+ } else if (flags & CONFLICT_ARCHIVE_OVERWRITTEN) {
+ icons.append(":/MO/gui/archive_conflict_loser");
+ }
+
+ return icons;
+}
+
+[[nodiscard]] static int numIcons(uint flags)
+{
+ return ((flags & CONFLICT_MIXED) ? 1 : 0) +
+ ((flags & CONFLICT_ARCHIVE_MIXED) ? 1 : 0);
+}
+
+int ConflictIconDelegate::getNumIcons(const QModelIndex& index) const
+{
+ return numIcons(m_View->conflictFlags(index));
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h
new file mode 100644
index 0000000..6343328
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/ConflictIconDelegate.h
@@ -0,0 +1,28 @@
+#ifndef BSPLUGINLIST_CONFLICTICONDELEGATE_H
+#define BSPLUGINLIST_CONFLICTICONDELEGATE_H
+
+#include "GUI/IconDelegate.h"
+#include "PluginListView.h"
+#include "TESData/FileInfo.h"
+
+namespace BSPluginList
+{
+
+class ConflictIconDelegate final : public GUI::IconDelegate
+{
+ Q_OBJECT
+
+public:
+ explicit ConflictIconDelegate(PluginListView* view);
+
+protected:
+ [[nodiscard]] QList<QString> getIcons(const QModelIndex& index) const override;
+ [[nodiscard]] int getNumIcons(const QModelIndex& index) const override;
+
+private:
+ const PluginListView* m_View;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_CONFLICTICONDELEGATE_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp
new file mode 100644
index 0000000..72be162
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.cpp
@@ -0,0 +1,67 @@
+#include "FlagIconDelegate.h"
+
+#include <bit>
+
+namespace BSPluginList
+{
+
+using enum TESData::FileInfo::EFlag;
+
+FlagIconDelegate::FlagIconDelegate(PluginListView* view)
+ : GUI::IconDelegate(view), m_View{view}
+{}
+
+QList<QString> FlagIconDelegate::getIcons(const QModelIndex& index) const
+{
+ const auto flags = m_View->fileFlags(index);
+
+ QList<QString> icons;
+
+ if (flags & FLAG_PROBLEMATIC) {
+ icons.append(":/MO/gui/warning");
+ }
+
+ if (flags & FLAG_INFORMATION) {
+ icons.append(":/MO/gui/information");
+ }
+
+ if (flags & FLAG_INI) {
+ icons.append(":/MO/gui/attachment");
+ }
+
+ if (flags & FLAG_BSA) {
+ icons.append(":/MO/gui/archive_conflict_neutral");
+ }
+
+ if (flags & FLAG_MASTER) {
+ icons.append(":/bsplugins/star");
+ }
+
+ if (flags & FLAG_LIGHT) {
+ icons.append(":/bsplugins/feather");
+ }
+
+ if (flags & FLAG_MEDIUM) {
+ icons.append(":/MO/gui/run");
+ if (flags & FLAG_LIGHT) {
+ icons.append(":/MO/gui/warning");
+ }
+ }
+
+ if (flags & FLAG_BLUEPRINT) {
+ icons.append(":/MO/gui/resources/go-down.png");
+ }
+
+ if (flags & FLAG_CLEAN) {
+ icons.append(":/MO/gui/edit_clear");
+ }
+
+ return icons;
+}
+
+int FlagIconDelegate::getNumIcons(const QModelIndex& index) const
+{
+ return std::popcount(static_cast<uint>(m_View->fileFlags(index)));
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h
new file mode 100644
index 0000000..7382233
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/FlagIconDelegate.h
@@ -0,0 +1,28 @@
+#ifndef BSPLUGINLIST_FLAGICONDELEGATE_H
+#define BSPLUGINLIST_FLAGICONDELEGATE_H
+
+#include "GUI/IconDelegate.h"
+#include "PluginListView.h"
+#include "TESData/FileInfo.h"
+
+namespace BSPluginList
+{
+
+class FlagIconDelegate final : public GUI::IconDelegate
+{
+ Q_OBJECT
+
+public:
+ explicit FlagIconDelegate(PluginListView* view);
+
+protected:
+ [[nodiscard]] QList<QString> getIcons(const QModelIndex& index) const override;
+ [[nodiscard]] int getNumIcons(const QModelIndex& index) const override;
+
+private:
+ const PluginListView* m_View;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_FLAGICONDELEGATE_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp
new file mode 100644
index 0000000..fb5adcc
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.cpp
@@ -0,0 +1,594 @@
+#include "PluginGroupProxyModel.h"
+#include "PluginListDropInfo.h"
+#include "PluginListModel.h"
+
+#include <QSortFilterProxyModel>
+
+namespace BSPluginList
+{
+
+PluginGroupProxyModel::PluginGroupProxyModel(MOBase::IOrganizer* organizer,
+ QObject* parent)
+ : QAbstractProxyModel(parent), m_Organizer{organizer}
+{}
+
+void PluginGroupProxyModel::setSourceModel(QAbstractItemModel* sourceModel)
+{
+ emit beginResetModel();
+
+ if (const auto oldSource = this->sourceModel()) {
+ disconnect(oldSource, nullptr, this, nullptr);
+ }
+
+ QAbstractProxyModel::setSourceModel(sourceModel);
+
+ if (sourceModel) {
+ connect(sourceModel, &QAbstractItemModel::layoutChanged, this,
+ &PluginGroupProxyModel::onSourceLayoutChanged, Qt::UniqueConnection);
+ connect(sourceModel, &QAbstractItemModel::rowsInserted, this,
+ &PluginGroupProxyModel::onSourceRowsInserted, Qt::UniqueConnection);
+ connect(sourceModel, &QAbstractItemModel::rowsRemoved, this,
+ &PluginGroupProxyModel::onSourceRowsRemoved, Qt::UniqueConnection);
+ connect(sourceModel, &QAbstractItemModel::modelReset, this,
+ &PluginGroupProxyModel::onSourceModelReset, Qt::UniqueConnection);
+ connect(sourceModel, &QAbstractItemModel::dataChanged, this,
+ &PluginGroupProxyModel::onSourceDataChanged, Qt::UniqueConnection);
+
+ buildGroups();
+ }
+
+ emit endResetModel();
+}
+
+bool PluginGroupProxyModel::hasChildren(const QModelIndex& parent) const
+{
+ return rowCount(parent) > 0;
+}
+
+int PluginGroupProxyModel::rowCount(const QModelIndex& parent) const
+{
+ if (!parent.isValid()) {
+ return static_cast<int>(m_TopLevel.size());
+ } else {
+ const auto& item = m_ProxyItems.at(parent.internalId());
+ const auto& group = item.groupInfo;
+ return group ? static_cast<int>(group->children.size()) : 0;
+ }
+}
+
+int PluginGroupProxyModel::columnCount([[maybe_unused]] const QModelIndex& parent) const
+{
+ return sourceModel()->columnCount();
+}
+
+QModelIndex PluginGroupProxyModel::index(int row, int column,
+ const QModelIndex& parent) const
+{
+ if (row < 0 || column < 0) {
+ return QModelIndex();
+ }
+
+ if (parent.isValid()) {
+ const auto& item = m_ProxyItems.at(parent.internalId());
+ if (const auto& group = item.groupInfo) {
+ if (row < group->children.size()) {
+ const auto id = group->children[row];
+ return createIndex(row, column, id);
+ }
+ }
+ } else {
+ if (row < m_TopLevel.size()) {
+ const auto id = m_TopLevel[row];
+ return createIndex(row, column, id);
+ }
+ }
+ return QModelIndex();
+}
+
+QModelIndex PluginGroupProxyModel::parent(const QModelIndex& index) const
+{
+ const auto& item = m_ProxyItems.at(index.internalId());
+ const auto parentId = item.parentId;
+ if (parentId == NO_ID) {
+ return QModelIndex();
+ } else {
+ const auto& parent = m_ProxyItems.at(parentId);
+ return createIndex(parent.row, 0, parentId);
+ }
+}
+
+QModelIndex PluginGroupProxyModel::mapFromSource(const QModelIndex& sourceIndex) const
+{
+ if (!sourceIndex.isValid()) {
+ return QModelIndex();
+ }
+
+ const auto id = m_SourceMap[sourceIndex.row()];
+ const auto& item = m_ProxyItems.at(id);
+ QModelIndex parentIndex;
+ if (item.parentId != NO_ID) {
+ const auto& parentItem = m_ProxyItems.at(item.parentId);
+ parentIndex = index(parentItem.row, 0);
+ }
+ return index(item.row, sourceIndex.column(), parentIndex);
+}
+
+QModelIndex PluginGroupProxyModel::mapToSource(const QModelIndex& proxyIndex) const
+{
+ if (!proxyIndex.isValid()) {
+ return QModelIndex();
+ }
+
+ const auto& item = m_ProxyItems.at(proxyIndex.internalId());
+ return sourceModel()->index(item.sourceRow, proxyIndex.column());
+}
+
+Qt::ItemFlags PluginGroupProxyModel::flags(const QModelIndex& index) const
+{
+ if (!index.isValid()) {
+ return QAbstractProxyModel::flags(index);
+ }
+
+ const auto& item = m_ProxyItems.at(index.internalId());
+ if (!item.isGroup()) {
+ if (item.isSourceItem()) {
+ return sourceModel()->flags(mapToSource(index)) | Qt::ItemNeverHasChildren;
+ } else {
+ return Qt::ItemNeverHasChildren;
+ }
+ }
+
+ Qt::ItemFlags result = QAbstractProxyModel::flags(index);
+ if (index.column() == PluginListModel::COL_NAME) {
+ result |= Qt::ItemIsEditable;
+ }
+ result |= Qt::ItemIsSelectable;
+ result |= Qt::ItemIsDragEnabled;
+ result |= Qt::ItemIsDropEnabled;
+ result |= Qt::ItemIsEnabled;
+ return result;
+}
+
+static QVariant groupData(const QString& name, int column, int role)
+{
+ switch (role) {
+ case Qt::DisplayRole:
+ case Qt::EditRole:
+ switch (column) {
+ case PluginListModel::COL_NAME:
+ return name;
+ default:
+ return QVariant();
+ }
+ case Qt::FontRole: {
+ QFont result;
+ if (column == PluginListModel::COL_NAME) {
+ result.setItalic(true);
+ result.setBold(true);
+ }
+ return result;
+ }
+ case Qt::TextAlignmentRole:
+ return QVariant(Qt::AlignHCenter | Qt::AlignVCenter);
+ default:
+ return QVariant();
+ }
+}
+
+QVariant PluginGroupProxyModel::data(const QModelIndex& index, int role) const
+{
+ const auto& item = m_ProxyItems.at(index.internalId());
+ if (item.isSourceItem()) {
+ return sourceModel()->data(mapToSource(index), role);
+ }
+
+ if (const auto& group = item.groupInfo) {
+ return groupData(group->name, index.column(), role);
+ }
+
+ return QVariant();
+}
+
+bool PluginGroupProxyModel::setData(const QModelIndex& index, const QVariant& value,
+ int role)
+{
+ const auto& item = m_ProxyItems.at(index.internalId());
+ if (item.isSourceItem()) {
+ return sourceModel()->setData(mapToSource(index), value, role);
+ }
+
+ if (const auto& group = item.groupInfo) {
+ if (role == Qt::EditRole) {
+ if (index.column() == 0) {
+ const QString valueString = value.toString();
+ if (valueString.isEmpty())
+ return false;
+
+ emit groupRenameRequested(index, valueString);
+ }
+ }
+ }
+
+ return false;
+}
+
+QModelIndex PluginGroupProxyModel::buddy(const QModelIndex& index) const
+{
+ const auto& item = m_ProxyItems.at(index.internalId());
+ if (item.isSourceItem()) {
+ return mapFromSource(sourceModel()->buddy(mapToSource(index)));
+ }
+
+ return index;
+}
+
+QVariant PluginGroupProxyModel::headerData(int section, Qt::Orientation orientation,
+ int role) const
+{
+ return sourceModel()->headerData(section, orientation, role);
+}
+
+int PluginGroupProxyModel::mapLowerBoundToSourceRow(std::size_t id) const
+{
+ const auto& item = m_ProxyItems.at(id);
+ if (item.isSourceItem()) {
+ return item.sourceRow;
+ } else if (item.isGroup()) {
+ const auto child = item.groupInfo->children.front();
+ const auto& childItem = m_ProxyItems.at(child);
+ return childItem.sourceRow;
+ } else {
+ if (item.row + 1 == m_TopLevel.size()) {
+ return -1;
+ }
+ const auto next = m_TopLevel.at(item.row + 1);
+ return mapLowerBoundToSourceRow(next);
+ }
+}
+
+bool PluginGroupProxyModel::isAboveDivider(std::size_t id) const
+{
+ const auto& item = m_ProxyItems.at(id);
+ return item.isDivider();
+}
+
+bool PluginGroupProxyModel::isBelowDivider(std::size_t id) const
+{
+ const auto& item = m_ProxyItems.at(id);
+ if (item.parentId != NO_ID) {
+ return item.row == 0 && isBelowDivider(item.parentId);
+ } else {
+ return item.row > 0 && m_ProxyItems.at(m_TopLevel.at(item.row - 1)).isDivider();
+ }
+}
+
+QMimeData* PluginGroupProxyModel::mimeData(const QModelIndexList& indexes) const
+{
+ m_DraggingGroups.clear();
+
+ QModelIndexList sourceIndexes;
+
+ for (const auto& idx : indexes) {
+ const auto sourceIndex = mapToSource(idx);
+ if (sourceIndex.isValid()) {
+ sourceIndexes.append(sourceIndex);
+ } else {
+ m_DraggingGroups.push_back(idx.internalId());
+ for (int i = 0, count = rowCount(idx); i < count; ++i) {
+ sourceIndexes.append(mapToSource(index(i, 0, idx)));
+ }
+ }
+ }
+
+ return sourceModel()->mimeData(sourceIndexes);
+}
+
+bool PluginGroupProxyModel::canDropMimeData(const QMimeData* data,
+ Qt::DropAction action, int row, int column,
+ const QModelIndex& parent_) const
+{
+ // HACK: fix drop below expanded item
+ auto parent = parent_;
+ if (m_DroppingBelowExpandedItem) {
+ parent = index(row - 1, column, parent_);
+ row = 0;
+ }
+
+ const bool draggedOntoGroup = parent.isValid() && row == -1;
+ const bool draggedToBottom = parent.isValid() && row == rowCount(parent);
+ const auto idx = draggedOntoGroup || draggedToBottom ? index(parent.row() + 1, 0)
+ : index(row, column, parent);
+ const int sourceRow = idx.isValid() ? mapLowerBoundToSourceRow(idx.internalId()) : -1;
+
+ bool canDrop = true;
+ if (isBelowDivider(idx.internalId())) {
+ canDrop = canDrop && sourceModel()->canDropMimeData(data, action, sourceRow + 1, 0,
+ QModelIndex());
+ }
+ if (isAboveDivider(idx.internalId())) {
+ canDrop = canDrop && sourceModel()->canDropMimeData(data, action, sourceRow - 1, 0,
+ QModelIndex());
+ }
+
+ return canDrop &&
+ sourceModel()->canDropMimeData(data, action, sourceRow, 0, QModelIndex());
+}
+
+template <typename T>
+static T* findBaseModel(QAbstractItemModel* sourceModel)
+{
+ for (auto nextModel = sourceModel; nextModel;) {
+ const auto proxyModel = qobject_cast<QAbstractProxyModel*>(nextModel);
+ if (proxyModel) {
+ nextModel = proxyModel->sourceModel();
+ } else {
+ if (const auto baseModel = qobject_cast<T*>(nextModel)) {
+ return baseModel;
+ }
+ nextModel = nullptr;
+ }
+ }
+ return nullptr;
+}
+
+bool PluginGroupProxyModel::dropMimeData(const QMimeData* data, Qt::DropAction action,
+ int row, int column,
+ const QModelIndex& parent_)
+{
+ // HACK: fix drop below expanded item
+ auto parent = parent_;
+ if (m_DroppingBelowExpandedItem) {
+ parent = index(row - 1, column, parent_);
+ row = 0;
+ }
+
+ const bool draggedOntoGroup = parent.isValid() && row == -1;
+ const bool draggedToBottom = parent.isValid() && row == rowCount(parent);
+ const auto idx = draggedOntoGroup || draggedToBottom ? index(parent.row() + 1, 0)
+ : index(row, column, parent);
+ const int sourceRow = idx.isValid() ? mapLowerBoundToSourceRow(idx.internalId()) : -1;
+
+ QString groupName;
+ const auto baseModel = findBaseModel<PluginListModel>(sourceModel());
+ PluginListDropInfo dropInfo{data, row, parent,
+ baseModel ? baseModel->m_Plugins : nullptr};
+ if (parent.isValid()) {
+ QModelIndex groupIdx = parent;
+ while (groupIdx.parent().isValid()) {
+ groupIdx = groupIdx.parent();
+ }
+
+ const auto& parentItem = m_ProxyItems.at(groupIdx.internalId());
+ const auto& groupInfo = parentItem.groupInfo;
+ groupName = groupInfo ? groupInfo->name : QString();
+ }
+
+ if (baseModel) {
+ auto regroupRows = dropInfo.sourceRows();
+ if (draggedToBottom || groupName.isEmpty()) {
+ for (const std::size_t id : m_DraggingGroups) {
+ const auto& groupItem = m_ProxyItems.at(id);
+ const auto group = groupItem.groupInfo;
+
+ for (const std::size_t childId : group->children) {
+ const auto& childItem = m_ProxyItems.at(childId);
+ const auto childIndex = createIndex(childItem.row, 0, childId);
+
+ std::erase(regroupRows, childIndex.data(PluginListModel::IndexRole).toInt());
+ }
+ }
+ }
+
+ baseModel->m_Plugins->setGroup(regroupRows, groupName);
+ }
+ m_DraggingGroups.clear();
+
+ if (!sourceModel()->dropMimeData(data, action, sourceRow, 0, QModelIndex())) {
+ return false;
+ }
+
+ emit layoutAboutToBeChanged();
+ buildGroups();
+ emit layoutChanged();
+
+ return true;
+}
+
+void PluginGroupProxyModel::onSourceDataChanged(const QModelIndex& topLeft,
+ const QModelIndex& bottomRight,
+ const QList<int>& roles)
+{
+ const auto topLeftProxy = mapFromSource(topLeft);
+ const auto bottomRightProxy = mapFromSource(bottomRight);
+
+ emit dataChanged(topLeftProxy, bottomRightProxy, roles);
+
+ if (!roles.isEmpty() && !roles.contains(PluginListModel::GroupingRole)) {
+ return;
+ }
+
+ emit layoutAboutToBeChanged();
+ buildGroups();
+ emit layoutChanged();
+}
+
+void PluginGroupProxyModel::onSourceLayoutChanged(
+ [[maybe_unused]] const QList<QPersistentModelIndex>& parents,
+ QAbstractItemModel::LayoutChangeHint hint)
+{
+ emit layoutAboutToBeChanged({}, hint);
+ buildGroups();
+ emit layoutChanged({}, hint);
+}
+
+void PluginGroupProxyModel::onSourceModelReset()
+{
+ emit layoutAboutToBeChanged();
+ buildGroups();
+ emit layoutChanged();
+}
+
+void PluginGroupProxyModel::onSourceRowsInserted(const QModelIndex& parent)
+{
+ if (parent.isValid()) {
+ return;
+ }
+
+ emit layoutAboutToBeChanged();
+ buildGroups();
+ emit layoutChanged();
+}
+
+void PluginGroupProxyModel::onSourceRowsRemoved(const QModelIndex& parent)
+{
+ if (parent.isValid()) {
+ return;
+ }
+
+ emit layoutAboutToBeChanged();
+ buildGroups();
+ emit layoutChanged();
+}
+
+void PluginGroupProxyModel::buildGroups()
+{
+ m_TopLevel.clear();
+ m_SourceMap.clear();
+
+ const auto sortProxy = qobject_cast<const QSortFilterProxyModel*>(sourceModel());
+ const bool sorted =
+ sortProxy && sortProxy->sortColumn() == PluginListModel::COL_PRIORITY;
+
+ int primaryDivider = -1;
+ int masterDivider = -1;
+ int blueprintDivider = -1;
+ for (int i = 0; i < sourceModel()->rowCount(); ++i) {
+ const auto idx = sourceModel()->index(i, 0);
+ const auto plugin =
+ idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+
+ if (sorted && plugin) {
+ if (sortProxy->sortOrder() == Qt::AscendingOrder) {
+ if (blueprintDivider == -1 && plugin->isBlueprintFile()) {
+ blueprintDivider = i;
+ } else if (plugin->forceLoaded()) {
+ primaryDivider = i;
+ } else if (plugin->isMasterFile()) {
+ masterDivider = i;
+ }
+ } else {
+ if (plugin->isBlueprintFile()) {
+ blueprintDivider = i - 1;
+ } else if (masterDivider == -1 && plugin->isMasterFile()) {
+ masterDivider = i - 1;
+ } else if (primaryDivider == -1 && plugin->forceLoaded()) {
+ primaryDivider = i - 1;
+ break;
+ }
+ }
+ }
+ }
+
+ boost::container::flat_map<QString, int> groupRepeats;
+
+ QString lastGroup;
+ std::size_t groupId = NO_ID;
+ for (int i = 0, count = sourceModel()->rowCount(); i < count; ++i) {
+ const auto idx = sourceModel()->index(i, 0);
+ const auto info =
+ idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+ const QString name = info ? info->name() : QString();
+ const QString group = idx.data(PluginListModel::GroupingRole).toString();
+
+ if (sorted && group != lastGroup) {
+ lastGroup = group;
+
+ if (group.isNull()) {
+ groupId = NO_ID;
+ } else {
+ const int row = static_cast<int>(m_TopLevel.size());
+ groupId = createItem(group, row, -1, NO_ID, std::make_shared<Group>(group),
+ groupRepeats[group]++);
+ m_TopLevel.push_back(groupId);
+ }
+ }
+
+ {
+ auto& siblings =
+ groupId == NO_ID ? m_TopLevel : m_ProxyItems[groupId].groupInfo->children;
+
+ const int row = static_cast<int>(siblings.size());
+ const auto id = createItem(name, row, i, groupId, nullptr);
+ siblings.push_back(id);
+ m_SourceMap.push_back(id);
+ }
+
+ if (sorted &&
+ (i == primaryDivider || i == masterDivider || i == blueprintDivider) &&
+ (i != 0 && i != count - 1)) {
+ lastGroup = QString();
+ groupId = NO_ID;
+
+ const QString key = QString();
+ const int row = static_cast<int>(m_TopLevel.size());
+ const auto id = createItem(key, row, -1, NO_ID, nullptr, groupRepeats[key]++);
+ m_TopLevel.push_back(id);
+ }
+ }
+
+ for (std::size_t id = 0; id < m_ProxyItems.size(); ++id) {
+ auto& item = m_ProxyItems[id];
+ if (item.row < 0)
+ continue;
+
+ bool invalidate = false;
+ if (item.parentId == NO_ID) {
+ if (item.row >= m_TopLevel.size() || m_TopLevel[item.row] != id) {
+ invalidate = true;
+ }
+ } else {
+ const auto& parentItem = m_ProxyItems.at(item.parentId);
+ if (const auto group = parentItem.groupInfo) {
+ if (item.row >= group->children.size() || group->children[item.row] != id) {
+ invalidate = true;
+ }
+ }
+ }
+
+ if (invalidate) {
+ // we want to try to keep items alive even if they are temporarily removed from
+ // the model, so assign them to an index that looks valid but won't be displayed
+ const int fakeRow = static_cast<int>(m_TopLevel.size());
+ for (int column = 0, count = columnCount(); column < count; ++column) {
+ changePersistentIndex(createIndex(item.row, column, id),
+ createIndex(fakeRow, column, id));
+ }
+ item = ProxyItem{fakeRow, -1, NO_ID, nullptr};
+ }
+ }
+}
+
+std::size_t PluginGroupProxyModel::createItem(const QString& name, int row,
+ int sourceRow, std::size_t parent,
+ std::shared_ptr<Group> group, int repeat)
+{
+ if (m_ItemMap.count(name) <= repeat) {
+ const auto id = m_ProxyItems.size();
+ m_ItemMap.emplace(name, id);
+ m_ProxyItems.emplace_back(row, sourceRow, parent, std::move(group));
+ return id;
+ } else {
+ const auto it = m_ItemMap.find(name) + repeat;
+ auto& item = m_ProxyItems.at(it->second);
+ if (row != item.row) {
+ for (int column = 0, count = columnCount(); column < count; ++column) {
+ changePersistentIndex(createIndex(item.row, column, it->second),
+ createIndex(row, column, it->second));
+ }
+ }
+ item = ProxyItem{row, sourceRow, parent, std::move(group)};
+ return it->second;
+ }
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h
new file mode 100644
index 0000000..c0f0f1c
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginGroupProxyModel.h
@@ -0,0 +1,118 @@
+#ifndef BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H
+#define BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H
+
+#include "TESData/FileInfo.h"
+
+#include <imoinfo.h>
+
+#include <boost/container/flat_map.hpp>
+
+#include <QAbstractProxyModel>
+
+#include <limits>
+#include <memory>
+#include <vector>
+
+namespace BSPluginList
+{
+
+class PluginGroupProxyModel final : public QAbstractProxyModel
+{
+ Q_OBJECT
+
+public:
+ explicit PluginGroupProxyModel(MOBase::IOrganizer* organizer,
+ QObject* parent = nullptr);
+
+ void setSourceModel(QAbstractItemModel* sourceModel) override;
+
+ bool hasChildren(const QModelIndex& parent = QModelIndex()) const override;
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex& parent = QModelIndex()) const override;
+
+ QModelIndex index(int row, int column,
+ const QModelIndex& parent = QModelIndex()) const override;
+ QModelIndex parent(const QModelIndex& index) const override;
+
+ QModelIndex mapFromSource(const QModelIndex& sourceIndex) const override;
+ QModelIndex mapToSource(const QModelIndex& proxyIndex) const override;
+
+ Qt::ItemFlags flags(const QModelIndex& index) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole) override;
+ QModelIndex buddy(const QModelIndex& index) const override;
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role = Qt::DisplayRole) const override;
+
+ QMimeData* mimeData(const QModelIndexList& indexes) const override;
+ bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row,
+ int column, const QModelIndex& parent) const override;
+ bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column,
+ const QModelIndex& parent) override;
+
+ void setDroppingBelowExpandedItem(bool value) { m_DroppingBelowExpandedItem = value; }
+
+signals:
+ void groupRenameRequested(const QModelIndex& index, const QString& name);
+
+private slots:
+ void onSourceDataChanged(const QModelIndex& topLeft, const QModelIndex& bottomRight,
+ const QList<int>& roles = QList<int>());
+ void onSourceLayoutChanged(
+ const QList<QPersistentModelIndex>& parents = QList<QPersistentModelIndex>(),
+ QAbstractItemModel::LayoutChangeHint hint =
+ QAbstractItemModel::NoLayoutChangeHint);
+ void onSourceModelReset();
+ void onSourceRowsInserted(const QModelIndex& parent);
+ void onSourceRowsRemoved(const QModelIndex& parent);
+
+private:
+ static constexpr std::size_t NO_ID = std::numeric_limits<std::size_t>::max();
+
+ struct Group
+ {
+ QString name;
+ std::vector<std::size_t> children;
+
+ explicit Group(const QString& name) : name{name} {}
+ };
+
+ struct ProxyItem
+ {
+ int row;
+ int sourceRow;
+ std::size_t parentId;
+ std::shared_ptr<Group> groupInfo;
+
+ [[nodiscard]] bool isSourceItem() const { return sourceRow != -1; }
+ [[nodiscard]] bool isGroup() const { return groupInfo != nullptr; }
+ [[nodiscard]] bool isDivider() const
+ {
+ return groupInfo == nullptr && sourceRow == -1;
+ }
+ };
+
+ [[nodiscard]] int mapLowerBoundToSourceRow(std::size_t id) const;
+ [[nodiscard]] bool isAboveDivider(std::size_t id) const;
+ [[nodiscard]] bool isBelowDivider(std::size_t id) const;
+
+ void buildGroups();
+ [[nodiscard]] std::size_t createItem(const QString& name, int row, int sourceRow,
+ std::size_t parent, std::shared_ptr<Group> group,
+ int repeat = 0);
+
+ std::vector<ProxyItem> m_ProxyItems;
+ boost::container::flat_multimap<QString, std::size_t> m_ItemMap;
+ std::vector<std::size_t> m_TopLevel;
+ std::vector<std::size_t> m_SourceMap;
+
+ bool m_DroppingBelowExpandedItem;
+ mutable std::vector<std::size_t> m_DraggingGroups;
+
+ MOBase::IOrganizer* m_Organizer;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINGROUPPROXYMODEL_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp
new file mode 100644
index 0000000..9fc53ac
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.cpp
@@ -0,0 +1,349 @@
+#include "PluginListContextMenu.h"
+#include "GUI/ListDialog.h"
+#include "MOPlugin/Settings.h"
+#include "PluginListModel.h"
+#include "PluginListView.h"
+#include "TESData/FileInfo.h"
+
+#include <utility.h>
+
+#include <QInputDialog>
+#include <QMessageBox>
+
+#include <algorithm>
+#include <limits>
+#include <utility>
+
+namespace BSPluginList
+{
+
+PluginListContextMenu::PluginListContextMenu(const QModelIndex& index,
+ PluginListModel* model,
+ PluginListView* view,
+ MOBase::IModList* modList,
+ MOBase::IPluginList* pluginList)
+ : QMenu(view), m_Index{index}, m_Model{model}, m_View{view}
+{
+ m_ViewSelected = view->selectionModel()->selectedRows();
+ if (!m_ViewSelected.isEmpty()) {
+ m_ModelSelected = view->indexViewToModel(m_ViewSelected, model);
+ } else if (index.isValid()) {
+ m_ModelSelected = {index};
+ }
+
+ m_FilesSelected =
+ !m_ViewSelected.isEmpty() && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) {
+ return !idx.model()->hasChildren(idx);
+ });
+
+ m_GroupsSelected =
+ !m_ViewSelected.isEmpty() && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) {
+ return idx.model()->hasChildren(idx);
+ });
+
+ m_FilesTogglable =
+ m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) {
+ const auto plugin =
+ idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+ return plugin && plugin->canBeToggled();
+ });
+
+ m_FilesESM =
+ m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) {
+ const auto plugin =
+ idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+ return plugin && !plugin->forceLoaded() && plugin->isMasterFile();
+ });
+
+ m_FilesESP =
+ m_FilesSelected && std::ranges::all_of(m_ViewSelected, [](const QModelIndex& idx) {
+ const auto plugin =
+ idx.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+ return plugin && !plugin->forceLoaded() && !plugin->isMasterFile();
+ });
+
+ m_FilesMovable = m_FilesESM | m_FilesESP;
+
+ addAllItemsMenu();
+ addSelectedFilesActions();
+ addSelectedGroupActions();
+ addSelectionActions();
+ addOriginActions(modList, pluginList);
+}
+
+void PluginListContextMenu::addAllItemsMenu()
+{
+ QMenu* const allItemsMenu = addMenu(tr("All Items"));
+
+ allItemsMenu->addAction(tr("Collapse all"), [this]() {
+ m_View->collapseAll();
+ m_View->scrollToTop();
+ });
+ allItemsMenu->addAction(tr("Expand all"), [this]() {
+ m_View->expandAll();
+ m_View->scrollToTop();
+ });
+
+ allItemsMenu->addSeparator();
+
+ allItemsMenu->addAction(tr("Enable all"), [this]() {
+ if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"),
+ tr("Really enable all plugins?"),
+ QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
+ m_Model->setEnabledAll(true);
+ }
+ });
+ allItemsMenu->addAction(tr("Disable all"), [this]() {
+ if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"),
+ tr("Really disable all plugins?"),
+ QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
+ m_Model->setEnabledAll(false);
+ }
+ });
+}
+
+void PluginListContextMenu::addSelectedFilesActions()
+{
+ if (!m_FilesTogglable)
+ return;
+
+ addSeparator();
+
+ addAction(tr("Enable selected"), [this]() {
+ m_Model->setEnabled(m_ModelSelected, true);
+ });
+ addAction(tr("Disable selected"), [this]() {
+ m_Model->setEnabled(m_ModelSelected, false);
+ });
+}
+
+void PluginListContextMenu::addSelectedGroupActions()
+{
+ if (!m_GroupsSelected || m_ViewSelected.length() != 1)
+ return;
+
+ addSeparator();
+
+ const auto selectedIndex = m_ViewSelected.first();
+ const bool expanded = m_View->isExpanded(selectedIndex);
+ addAction(tr("Collapse others"), [=, this]() {
+ m_View->collapseAll();
+ m_View->setExpanded(selectedIndex, expanded);
+ m_View->scrollTo(selectedIndex);
+ });
+}
+
+void PluginListContextMenu::addSelectionActions()
+{
+ if (m_ModelSelected.isEmpty())
+ return;
+
+ addSeparator();
+
+ addSendToMenu();
+
+ if (m_FilesSelected) {
+ addAction(tr("Create Group..."), [this]() {
+ bool ok;
+ const QString group =
+ QInputDialog::getText(m_View->topLevelWidget(), tr("Create Group..."),
+ tr("Please enter a name:"), QLineEdit::Normal, "", &ok);
+
+ if (!ok || group.isEmpty())
+ return;
+
+ QList<QPersistentModelIndex> persistent;
+ persistent.reserve(m_ViewSelected.length());
+ std::ranges::transform(m_ViewSelected, std::back_inserter(persistent),
+ [](const QModelIndex& idx) {
+ return QPersistentModelIndex(idx);
+ });
+
+ const int priority = m_ModelSelected.first()
+ .siblingAtColumn(PluginListModel::COL_PRIORITY)
+ .data()
+ .toInt();
+ m_Model->sendToPriority(m_ModelSelected, priority);
+ m_Model->setGroup(m_ModelSelected, group);
+ for (const auto& index : persistent) {
+ m_View->setExpanded(index.parent(), true);
+ }
+ });
+ } else if (m_GroupsSelected) {
+ if (m_ViewSelected.length() == 1) {
+ addAction(tr("Rename Group..."), [this]() {
+ const auto& selected = m_ViewSelected.first();
+ QModelIndexList indices;
+ for (int i = 0, count = selected.model()->rowCount(selected); i < count; ++i) {
+ const auto child = selected.model()->index(i, 0, selected);
+ auto&& index = m_View->indexViewToModel(child, m_Model);
+ indices.append(std::move(index));
+ }
+
+ bool ok;
+ const QString group = QInputDialog::getText(
+ m_View->topLevelWidget(), tr("Rename Group..."), tr("Please enter a name:"),
+ QLineEdit::Normal, "", &ok);
+
+ if (!ok || group.isEmpty())
+ return;
+
+ const auto persistentIndex =
+ QPersistentModelIndex(selected.model()->index(0, 0, selected));
+ const bool expanded = m_View->isExpanded(selected);
+
+ m_Model->setGroup(indices, group);
+
+ const auto groupIndex = persistentIndex.parent();
+ const auto groupRight =
+ groupIndex.siblingAtColumn(selected.model()->columnCount() - 1);
+ m_View->setExpanded(groupIndex, expanded);
+ m_View->selectionModel()->select(QItemSelection(groupIndex, groupRight),
+ QItemSelectionModel::ClearAndSelect);
+ m_View->selectionModel()->setCurrentIndex(groupIndex,
+ QItemSelectionModel::Current);
+ });
+ }
+
+ addAction(tr("Remove Group..."), [this]() {
+ QModelIndexList indices;
+ for (const auto& selected : m_ViewSelected) {
+ for (int i = 0, count = selected.model()->rowCount(selected); i < count; ++i) {
+ const auto child = selected.model()->index(i, 0, selected);
+ auto&& index = m_View->indexViewToModel(child, m_Model);
+ indices.append(std::move(index));
+ }
+ }
+
+ if (QMessageBox::question(m_View->topLevelWidget(), tr("Confirm"),
+ tr("Are you sure you want to remove \"%1\"?")
+ .arg(m_ViewSelected.first().data().toString()),
+ QMessageBox::Yes | QMessageBox::No) ==
+ QMessageBox::Yes) {
+ m_Model->setGroup(indices, QString());
+ }
+ });
+ }
+}
+
+void PluginListContextMenu::addSendToMenu()
+{
+ if (!m_FilesMovable) {
+ return;
+ }
+
+ QMenu* const sendToMenu = addMenu(tr("Send to... "));
+ sendToMenu->addAction(tr("Top"), [this]() {
+ const auto selectedIndex = m_ViewSelected.first();
+ const auto persistentIndex = QPersistentModelIndex(selectedIndex);
+ m_Model->sendToPriority(m_ModelSelected, 0, true);
+ m_View->scrollTo(persistentIndex);
+ });
+ sendToMenu->addAction(tr("Bottom"), [this]() {
+ const auto selectedIndex = m_ViewSelected.first();
+ const auto persistentIndex = QPersistentModelIndex(selectedIndex);
+ m_Model->sendToPriority(m_ModelSelected, std::numeric_limits<int>::max(), true);
+ m_View->scrollTo(persistentIndex);
+ });
+ sendToMenu->addAction(tr("Priority..."), [this]() {
+ const auto selectedIndex = m_ViewSelected.first();
+
+ bool ok;
+ const int newPriority =
+ QInputDialog::getInt(m_View->topLevelWidget(), tr("Set Priority"),
+ tr("Set the priority of the selected plugins"), 0, 0,
+ std::numeric_limits<int>::max(), 1, &ok);
+ if (!ok)
+ return;
+
+ const auto persistentIndex = QPersistentModelIndex(selectedIndex);
+ m_Model->sendToPriority(m_ModelSelected, newPriority);
+ m_View->scrollTo(persistentIndex);
+ });
+ sendToMenu->addAction(tr("Group..."), [this]() {
+ sendSelectedToGroup();
+ });
+}
+
+static MOBase::IModInterface* getModInfo(const QModelIndex& index,
+ MOBase::IModList* modList,
+ MOBase::IPluginList* pluginList)
+{
+ const auto info =
+ index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+
+ if (!info) {
+ return nullptr;
+ }
+
+ const QString fileName = info->name();
+ return modList->getMod(pluginList->origin(fileName));
+}
+
+static void openOriginExplorer(const QModelIndexList& indices,
+ MOBase::IModList* modList,
+ MOBase::IPluginList* pluginList)
+{
+ for (const auto& idx : indices) {
+ if (const auto modInfo = getModInfo(idx, modList, pluginList)) {
+ MOBase::shell::Explore(modInfo->absolutePath());
+ }
+ }
+}
+
+void PluginListContextMenu::addOriginActions(MOBase::IModList* modList,
+ MOBase::IPluginList* pluginList)
+{
+ if (!m_Index.isValid())
+ return;
+
+ addSeparator();
+
+ const auto selectedIdx =
+ m_ModelSelected.length() == 1 ? m_ModelSelected.first() : m_Index;
+ const auto nameIdx = selectedIdx.siblingAtColumn(PluginListModel::COL_NAME);
+
+ if (std::ranges::any_of(m_ModelSelected, [=](auto&& idx) {
+ return getModInfo(idx, modList, pluginList) != nullptr;
+ })) {
+ addAction(tr("Open Origin in Explorer"), [=, this]() {
+ openOriginExplorer(m_ModelSelected, modList, pluginList);
+ });
+
+ const auto modInfo = getModInfo(nameIdx, modList, pluginList);
+ if (modInfo && !modInfo->isForeign()) {
+ addAction(tr("Open Origin Info..."), [this, nameIdx] {
+ emit openModInformation(nameIdx);
+ });
+ }
+ }
+
+ const auto pluginInfoAction = addAction("Open Plugin Info...", [this, nameIdx] {
+ emit openPluginInformation(nameIdx);
+ });
+ setDefaultAction(pluginInfoAction);
+}
+
+void PluginListContextMenu::sendSelectedToGroup()
+{
+ GUI::ListDialog dialog{*Settings::instance(), m_View->topLevelWidget()};
+ dialog.setWindowTitle(tr("Select a group..."));
+ if (m_FilesESM) {
+ dialog.setChoices(m_Model->masterGroups());
+ } else if (m_FilesESP) {
+ dialog.setChoices(m_Model->regularGroups());
+ }
+
+ if (dialog.exec() != QDialog::Accepted) {
+ return;
+ }
+
+ const QString result = dialog.getChoice();
+ if (result.isEmpty()) {
+ return;
+ }
+
+ m_Model->sendToGroup(m_ModelSelected, result, m_FilesESM);
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h
new file mode 100644
index 0000000..9526166
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListContextMenu.h
@@ -0,0 +1,54 @@
+#ifndef BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H
+#define BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H
+
+#include <imodlist.h>
+#include <ipluginlist.h>
+
+#include <QMenu>
+#include <QModelIndex>
+
+namespace BSPluginList
+{
+
+class PluginListModel;
+class PluginListView;
+
+class PluginListContextMenu final : public QMenu
+{
+ Q_OBJECT
+
+public:
+ PluginListContextMenu(const QModelIndex& index, PluginListModel* model,
+ PluginListView* view, MOBase::IModList* modList,
+ MOBase::IPluginList* pluginList);
+
+signals:
+ void openModInformation(const QModelIndex& index);
+ void openPluginInformation(const QModelIndex& index);
+
+private:
+ void addAllItemsMenu();
+ void addSelectedFilesActions();
+ void addSelectedGroupActions();
+ void addSelectionActions();
+ void addSendToMenu();
+ void addOriginActions(MOBase::IModList* modList, MOBase::IPluginList* pluginList);
+
+ void sendSelectedToGroup();
+
+ QModelIndex m_Index;
+ PluginListModel* m_Model;
+ PluginListView* m_View;
+ QModelIndexList m_ViewSelected;
+ QModelIndexList m_ModelSelected;
+ bool m_FilesSelected = false;
+ bool m_GroupsSelected = false;
+ bool m_FilesTogglable = false;
+ bool m_FilesESM = false;
+ bool m_FilesESP = false;
+ bool m_FilesMovable = false;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTCONTEXTMENU_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp
new file mode 100644
index 0000000..5ffda76
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.cpp
@@ -0,0 +1,42 @@
+#include "PluginListDropInfo.h"
+
+#include <QMap>
+#include <QMimeData>
+#include <QModelIndex>
+#include <QVariant>
+
+namespace BSPluginList
+{
+
+PluginListDropInfo::PluginListDropInfo(const QMimeData* data, int insertId,
+ const QModelIndex& parent,
+ const TESData::PluginList* plugins)
+{
+ QByteArray encoded = data->data("application/x-qabstractitemmodeldatalist");
+ QDataStream stream(&encoded, QIODevice::ReadOnly);
+
+ while (!stream.atEnd()) {
+ int sourceRow;
+ int col;
+ QMap<int, QVariant> roleDataMap;
+ stream >> sourceRow >> col >> roleDataMap;
+ if (col == 0) {
+ m_SourceRows.push_back(sourceRow);
+ }
+ }
+
+ if (insertId == -1) {
+ insertId = parent.row();
+ }
+
+ if (plugins) {
+ if (insertId < 0 || insertId >= plugins->pluginCount()) {
+ m_Destination = plugins->pluginCount();
+ } else {
+ const auto plugin = plugins->getPlugin(insertId);
+ m_Destination = plugin ? plugin->priority() : plugins->pluginCount();
+ }
+ }
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h
new file mode 100644
index 0000000..d50b27a
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListDropInfo.h
@@ -0,0 +1,29 @@
+#ifndef BSPLUGINLIST_PLUGINLISTDROPINFO_H
+#define BSPLUGINLIST_PLUGINLISTDROPINFO_H
+
+#include "TESData/PluginList.h"
+
+#include <vector>
+
+class QMimeData;
+
+namespace BSPluginList
+{
+
+class PluginListDropInfo final
+{
+public:
+ PluginListDropInfo(const QMimeData* data, int insertId, const QModelIndex& parent,
+ const TESData::PluginList* plugins);
+
+ const std::vector<int>& sourceRows() const { return m_SourceRows; }
+ const int destination() const { return m_Destination; }
+
+private:
+ std::vector<int> m_SourceRows;
+ int m_Destination;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTDROPINFO_H
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
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h
new file mode 100644
index 0000000..d0de9c6
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListModel.h
@@ -0,0 +1,137 @@
+#ifndef BSPLUGINLIST_PLUGINLISTMODEL_H
+#define BSPLUGINLIST_PLUGINLISTMODEL_H
+
+#include "TESData/PluginList.h"
+
+#include <QAbstractItemModel>
+
+#include <functional>
+
+namespace BSPluginList
+{
+
+class PluginListModel final : public QAbstractItemModel
+{
+ Q_OBJECT
+
+public:
+ friend class PluginGroupProxyModel;
+
+ enum ItemDataRole
+ {
+ GroupingRole = Qt::UserRole,
+ IndexRole,
+ InfoRole,
+ ConflictsIconRole,
+ FlagsIconRole,
+ OriginRole,
+ OverridingRole,
+ OverriddenRole,
+ OverwritingAuxRole,
+ OverwrittenAuxRole,
+ ScrollMarkRole,
+ };
+
+ enum EColumn
+ {
+ COL_NAME,
+ COL_CONFLICTS,
+ COL_FLAGS,
+ COL_PRIORITY,
+ COL_MODINDEX,
+ COL_FORMVERSION,
+ COL_HEADERVERSION,
+ COL_AUTHOR,
+ COL_DESCRIPTION,
+
+ COL_COUNT
+ };
+
+ explicit PluginListModel(TESData::PluginList* plugins);
+
+ QModelIndex index(int row, int column,
+ const QModelIndex& parent = QModelIndex()) const override;
+ QModelIndex parent(const QModelIndex& index) const override;
+
+ Qt::ItemFlags flags(const QModelIndex& index) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation,
+ int role) const override;
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex& parent = QModelIndex()) const override;
+
+ bool setData(const QModelIndex& index, const QVariant& value,
+ int role = Qt::EditRole) override;
+
+ Qt::DropActions supportedDropActions() const override;
+ bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row,
+ int column, const QModelIndex& parent) const override;
+ bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column,
+ const QModelIndex& parent) override;
+
+ [[nodiscard]] QStringList
+ groups(std::function<bool(const TESData::FileInfo*)> pred = {}) const;
+ [[nodiscard]] QStringList masterGroups() const;
+ [[nodiscard]] QStringList regularGroups() const;
+
+public slots:
+ void refresh();
+
+ void invalidate();
+ void invalidateConflicts();
+
+ void movePlugin(const QString& name, int oldPriority, int newPriority);
+
+ void
+ changePluginStates(const std::map<QString, MOBase::IPluginList::PluginStates>& infos);
+
+ // enable/disable all plugins
+ //
+ void setEnabledAll(bool enabled);
+
+ // enable/disable plugins at the given indices.
+ //
+ void setEnabled(const QModelIndexList& indices, bool enabled);
+
+ // send plugins to the given priority
+ //
+ void sendToPriority(const QModelIndexList& indices, int priority,
+ bool disjoint = false);
+
+ // shift the priority of mods at the given indices by the given offset
+ //
+ void shiftPluginsPriority(const QModelIndexList& indices, int offset);
+
+ // toggle the active state of mods at the given indices
+ //
+ void toggleState(const QModelIndexList& indices);
+
+ // assign plugins to a group
+ //
+ void setGroup(const QModelIndexList& indices, const QString& group);
+
+ // send plugins to the bottom of a group
+ //
+ void sendToGroup(const QModelIndexList& indices, const QString& group, bool isESM);
+
+signals:
+ void pluginStatesChanged(const QModelIndexList& indices) const;
+ void pluginOrderChanged() const;
+
+private:
+ [[nodiscard]] QVariant displayData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant checkstateData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant foregroundData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant backgroundData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant fontData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant alignmentData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant tooltipData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant conflictData(const QModelIndex& index) const;
+ [[nodiscard]] QVariant iconData(const QModelIndex& index) const;
+
+ TESData::PluginList* m_Plugins;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTMODEL_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp
new file mode 100644
index 0000000..141ef17
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.cpp
@@ -0,0 +1,119 @@
+#include "PluginListStyledItemDelegate.h"
+#include "PluginListModel.h"
+#include "PluginListView.h"
+
+#include <QApplication>
+
+namespace BSPluginList
+{
+
+PluginListStyledItemDelegate::PluginListStyledItemDelegate(PluginListView* view)
+ : QStyledItemDelegate(view), m_View{view}
+{}
+
+void PluginListStyledItemDelegate::paint(QPainter* painter,
+ const QStyleOptionViewItem& option,
+ const QModelIndex& index) const
+{
+ QStyleOptionViewItem opt(option);
+
+ if (index.column() == 0) {
+ if (!index.model()->hasChildren(index) || !index.data().isValid()) {
+ opt.rect.adjust(-m_View->indentation(), 0, 0, 0);
+ }
+ }
+
+ const auto color = m_View->markerColor(index);
+ opt.backgroundBrush = color;
+
+ const auto widget = opt.widget;
+ const auto style = widget ? widget->style() : QApplication::style();
+
+ if (!index.siblingAtColumn(0).data().isValid()) {
+ QStyleOptionFrame optFrame;
+ optFrame.initFrom(widget);
+ optFrame.rect = opt.rect;
+ optFrame.frameShape = QFrame::HLine;
+ optFrame.lineWidth = 0;
+ optFrame.midLineWidth = 1;
+ optFrame.state |= QStyle::State_Sunken;
+
+ QApplication::style()->drawControl(QStyle::CE_ShapedFrame, &optFrame, painter,
+ widget);
+ return;
+ }
+
+ const auto plugin =
+ index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+
+ bool disabledText = false;
+ switch (index.column()) {
+ case PluginListModel::COL_NAME:
+ disabledText = plugin && plugin->isAlwaysEnabled();
+ break;
+ case PluginListModel::COL_PRIORITY:
+ case PluginListModel::COL_MODINDEX:
+ disabledText = plugin && plugin->forceLoaded();
+ break;
+ }
+
+ if (disabledText) {
+ opt.palette.setBrush(QPalette::Text,
+ opt.palette.brush(QPalette::Disabled, QPalette::Text));
+ }
+
+ // HACK: we can't normally have a disabled checkbox on a selectable row, so create a
+ // margin for it and then draw one manually
+ bool drawCheck = false;
+ bool enabled = false;
+ if (index.column() == 0 && !index.data(Qt::CheckStateRole).isValid()) {
+ if (plugin) {
+ drawCheck = true;
+ if (plugin->forceLoaded() || plugin->forceEnabled()) {
+ enabled = true;
+ }
+ }
+
+ // set decoration size to use as text margin
+ opt.decorationSize.setWidth(
+ style->pixelMetric(QStyle::PM_IndicatorWidth, &opt, widget));
+ }
+
+ QStyledItemDelegate::paint(painter, opt, index);
+
+ // draw check on top
+ if (drawCheck) {
+ opt.features |= QStyleOptionViewItem::HasCheckIndicator;
+
+ QRect checkRect =
+ style->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, widget);
+ opt.rect = checkRect;
+
+ opt.state &= ~QStyle::State_Enabled;
+ if (enabled) {
+ opt.state |= QStyle::State_On;
+ }
+
+ style->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &opt, painter, widget);
+ }
+}
+
+void PluginListStyledItemDelegate::initStyleOption(QStyleOptionViewItem* option,
+ const QModelIndex& index) const
+{
+ const auto backgroundColor = option->backgroundBrush.color();
+ QStyledItemDelegate::initStyleOption(option, index);
+
+ // HACK: create a text margin where the checkbox should be
+ if (index.column() == 0 && !index.data(Qt::CheckStateRole).isValid()) {
+ if (index.data(PluginListModel::InfoRole).isValid()) {
+ option->features |= QStyleOptionViewItem::HasDecoration;
+ }
+ }
+
+ if (backgroundColor.isValid()) {
+ option->backgroundBrush = backgroundColor;
+ }
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h
new file mode 100644
index 0000000..9ef2b81
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListStyledItemDelegate.h
@@ -0,0 +1,31 @@
+#ifndef BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H
+#define BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H
+
+#include <QStyledItemDelegate>
+
+namespace BSPluginList
+{
+
+class PluginListView;
+
+class PluginListStyledItemDelegate final : public QStyledItemDelegate
+{
+ Q_OBJECT
+
+public:
+ explicit PluginListStyledItemDelegate(PluginListView* view);
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option,
+ const QModelIndex& index) const override;
+
+protected:
+ void initStyleOption(QStyleOptionViewItem* option,
+ const QModelIndex& index) const override;
+
+private:
+ PluginListView* m_View;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTSTYLEDITEMDELEGATE_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp
new file mode 100644
index 0000000..077adf9
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListView.cpp
@@ -0,0 +1,395 @@
+#include "PluginListView.h"
+
+#include "ConflictIconDelegate.h"
+#include "FlagIconDelegate.h"
+#include "GUI/CopyEventFilter.h"
+#include "MOPlugin/Settings.h"
+#include "PluginGroupProxyModel.h"
+#include "PluginListModel.h"
+#include "PluginListStyledItemDelegate.h"
+#include "PluginListViewMarkingScrollBar.h"
+#include "PluginSortFilterProxyModel.h"
+#include "TESData/PluginList.h"
+
+#include <widgetutility.h>
+
+#include <QHeaderView>
+#include <QSortFilterProxyModel>
+
+#include <stdexcept>
+
+namespace BSPluginList
+{
+
+PluginListView::PluginListView(QWidget* parent) : QTreeView(parent)
+{
+ setVerticalScrollBar(new PluginListViewMarkingScrollBar(this));
+ MOBase::setCustomizableColumns(this);
+ setItemDelegate(new PluginListStyledItemDelegate(this));
+ installEventFilter(new GUI::CopyEventFilter(this));
+}
+
+void PluginListView::setup()
+{
+ setItemDelegateForColumn(PluginListModel::COL_CONFLICTS,
+ new ConflictIconDelegate(this));
+ setItemDelegateForColumn(PluginListModel::COL_FLAGS, new FlagIconDelegate(this));
+
+ header()->resizeSection(PluginListModel::COL_NAME, 332);
+ header()->resizeSection(PluginListModel::COL_CONFLICTS, 71);
+ header()->resizeSection(PluginListModel::COL_FLAGS, 60);
+ header()->resizeSection(PluginListModel::COL_PRIORITY, 62);
+ header()->resizeSection(PluginListModel::COL_MODINDEX, 79);
+ header()->setSectionResizeMode(0, QHeaderView::Stretch);
+
+ connect(this, &QTreeView::collapsed, this, &PluginListView::updateOverwriteMarkers);
+ connect(this, &QTreeView::expanded, this, &PluginListView::updateOverwriteMarkers);
+ connect(selectionModel(), &QItemSelectionModel::selectionChanged, this,
+ &PluginListView::updateOverwriteMarkers);
+}
+
+void PluginListView::setModel(QAbstractItemModel* model)
+{
+ for (auto nextModel = model; nextModel;) {
+ const auto proxyModel = qobject_cast<QAbstractProxyModel*>(nextModel);
+ if (proxyModel) {
+ if (const auto groupProxy = qobject_cast<PluginGroupProxyModel*>(proxyModel)) {
+ connect(groupProxy, &PluginGroupProxyModel::groupRenameRequested, this,
+ &PluginListView::onGroupRenameRequested);
+ } else if (const auto sortProxy =
+ qobject_cast<PluginSortFilterProxyModel*>(proxyModel)) {
+ m_SortProxy = sortProxy;
+ }
+ nextModel = proxyModel->sourceModel();
+ } else {
+ if (const auto pluginModel = qobject_cast<PluginListModel*>(nextModel)) {
+ m_PluginModel = pluginModel;
+ } else {
+ throw std::logic_error("PluginListView's model should be a PluginListModel");
+ }
+ nextModel = nullptr;
+ }
+ }
+
+ QTreeView::setModel(model);
+}
+
+QRect PluginListView::visualRect(const QModelIndex& index) const
+{
+ QRect rect = QTreeView::visualRect(index);
+ if (index.column() == 0) {
+ if (index.isValid() && !index.model()->hasChildren(index) ||
+ !index.data().isValid()) {
+ rect.adjust(-indentation(), 0, 0, 0);
+ }
+ }
+ return rect;
+}
+
+QColor PluginListView::markerColor(const QModelIndex& index) const
+{
+ bool ok;
+ const uint pluginIndex = index.data(PluginListModel::IndexRole).toUInt(&ok);
+ if (ok) {
+ const bool highlight = m_Markers.highlight.contains(pluginIndex);
+ const bool overriding = m_Markers.overriding.contains(pluginIndex);
+ const bool overridden = m_Markers.overridden.contains(pluginIndex);
+ const bool overwritingAux = m_Markers.overwritingAux.contains(pluginIndex);
+ const bool overwrittenAux = m_Markers.overwrittenAux.contains(pluginIndex);
+
+ // the color logic looks backwards but this is what the mod list does
+ if (highlight) {
+ return Settings::instance()->containedColor();
+ } else if (overridden) {
+ return Settings::instance()->overwritingLooseFilesColor();
+ } else if (overriding) {
+ return Settings::instance()->overwrittenLooseFilesColor();
+ } else if (overwrittenAux) {
+ return Settings::instance()->overwritingArchiveFilesColor();
+ } else if (overwritingAux) {
+ return Settings::instance()->overwrittenArchiveFilesColor();
+ }
+ }
+
+ const auto rowIndex = index.siblingAtColumn(0);
+ if (model()->hasChildren(rowIndex) && !isExpanded(rowIndex)) {
+ std::vector<QColor> colors;
+ for (int i = 0; i < model()->rowCount(rowIndex); ++i) {
+ const auto childIndex = model()->index(i, index.column(), rowIndex);
+ const auto childColor = markerColor(childIndex);
+ if (childColor.isValid()) {
+ colors.push_back(childColor);
+ }
+ }
+
+ if (colors.empty()) {
+ return QColor();
+ }
+
+ int r = 0, g = 0, b = 0, a = 0;
+ for (const auto& color : colors) {
+ r += color.red();
+ g += color.green();
+ b += color.blue();
+ a += color.alpha();
+ }
+
+ return QColor(
+ static_cast<int>(r / colors.size()), static_cast<int>(g / colors.size()),
+ static_cast<int>(b / colors.size()), static_cast<int>(a / colors.size()));
+ }
+
+ return QColor();
+}
+
+uint PluginListView::fileFlags(const QModelIndex& index) const
+{
+ uint flags = index.data(PluginListModel::FlagsIconRole).toUInt();
+
+ if (model()->hasChildren(index) && !isExpanded(index.siblingAtColumn(0))) {
+ for (int i = 0, count = model()->rowCount(index); i < count; ++i) {
+ const auto child = model()->index(i, 0, index);
+ flags |= child.data(PluginListModel::FlagsIconRole).toUInt();
+ }
+ }
+
+ return flags;
+}
+
+uint PluginListView::conflictFlags(const QModelIndex& index) const
+{
+ uint flags = index.data(PluginListModel::ConflictsIconRole).toUInt();
+
+ if (model()->hasChildren(index) && !isExpanded(index.siblingAtColumn(0))) {
+ for (int i = 0, count = model()->rowCount(index); i < count; ++i) {
+ const auto child = model()->index(i, 0, index);
+ flags |= child.data(PluginListModel::ConflictsIconRole).toUInt();
+ }
+ }
+
+ return flags;
+}
+
+static void visitRows(const QAbstractItemModel* model,
+ std::function<void(const QModelIndex&)> visit,
+ const QModelIndex& root = QModelIndex())
+{
+ if (root.isValid()) {
+ visit(root);
+ }
+
+ for (int i = 0, count = model->rowCount(root); i < count; ++i) {
+ const auto idx = model->index(i, 0, root);
+ visitRows(model, visit, idx);
+ }
+}
+
+void PluginListView::setHighlightedOrigins(const QStringList& origins)
+{
+ m_Markers.highlight.clear();
+ visitRows(model(), [this, &origins](auto&& idx) {
+ const auto origin = idx.data(PluginListModel::OriginRole).toString();
+ if (origins.contains(origin)) {
+ m_Markers.highlight.insert(idx.data(PluginListModel::IndexRole).toUInt());
+ }
+ });
+
+ viewport()->update();
+ verticalScrollBar()->repaint();
+}
+
+void PluginListView::clearOverwriteMarkers()
+{
+ m_Markers.overriding.clear();
+ m_Markers.overridden.clear();
+ m_Markers.overwritingAux.clear();
+ m_Markers.overwrittenAux.clear();
+}
+
+void PluginListView::updateOverwriteMarkers()
+{
+ QModelIndexList indexes = selectionModel()->selectedRows();
+ for (const auto& idx : selectionModel()->selectedRows()) {
+ if (model()->hasChildren(idx) && !isExpanded(idx)) {
+ for (int i = 0, count = model()->rowCount(idx); i < count; ++i) {
+ indexes.append(model()->index(i, idx.column(), idx));
+ }
+ }
+ }
+
+ const auto insert = [](auto& dest, const auto& from) {
+ for (const QVariant& elem : from) {
+ dest.insert(elem.toUInt());
+ }
+ };
+
+ clearOverwriteMarkers();
+ for (const auto& idx : indexes) {
+ insert(m_Markers.overriding,
+ model()->data(idx, PluginListModel::OverridingRole).toList());
+ insert(m_Markers.overridden,
+ model()->data(idx, PluginListModel::OverriddenRole).toList());
+ insert(m_Markers.overwritingAux,
+ model()->data(idx, PluginListModel::OverwritingAuxRole).toList());
+ insert(m_Markers.overwrittenAux,
+ model()->data(idx, PluginListModel::OverwrittenAuxRole).toList());
+ }
+
+ viewport()->update();
+ verticalScrollBar()->repaint();
+}
+
+bool PluginListView::event(QEvent* event)
+{
+ if (event->type() == QEvent::KeyPress) {
+ QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
+
+ if (keyEvent->modifiers() == Qt::ControlModifier &&
+ (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter)) {
+
+ if (selectionModel()->hasSelection() &&
+ selectionModel()->selectedRows().count() == 1) {
+
+ QModelIndex idx = selectionModel()->currentIndex();
+ emit openOriginExplorer(idx);
+ return true;
+ }
+ }
+
+ bool sorted = false;
+ if (const auto proxy = m_SortProxy) {
+ sorted = proxy->sortColumn() == PluginListModel::COL_PRIORITY;
+ }
+
+ if (sorted && keyEvent->modifiers() == Qt::ControlModifier &&
+ (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down)) {
+ return moveSelection(keyEvent->key());
+ } else if (keyEvent->key() == Qt::Key_Space) {
+ return toggleSelectionState();
+ }
+ }
+
+ return QTreeView::event(event);
+}
+
+void PluginListView::dragMoveEvent(QDragMoveEvent* event)
+{
+ // HACK: dropping below an expanded item sends the same event as dropping below its
+ // children, so set an additional flag to signal this
+ if (const auto m = qobject_cast<PluginGroupProxyModel*>(model())) {
+ m->setDroppingBelowExpandedItem(dropIndicatorPosition() ==
+ QAbstractItemView::BelowItem &&
+ isExpanded(indexAt(event->position().toPoint())));
+ }
+
+ QTreeView::dragMoveEvent(event);
+}
+
+void PluginListView::paintEvent(QPaintEvent* event)
+{
+ if (m_FirstPaint) {
+ header()->setSectionResizeMode(0, QHeaderView::Interactive);
+ header()->setStretchLastSection(true);
+ m_FirstPaint = false;
+ }
+
+ QTreeView::paintEvent(event);
+}
+
+bool PluginListView::moveSelection(int key)
+{
+ if (m_PluginModel == nullptr) {
+ return false;
+ }
+
+ const auto sourceRows =
+ indexViewToModel(selectionModel()->selectedRows(), m_PluginModel, true);
+
+ const auto sortOrder = m_SortProxy ? m_SortProxy->sortOrder() : Qt::DescendingOrder;
+ const int offset = key == Qt::Key_Up && sortOrder == Qt::AscendingOrder ? -1 : 1;
+
+ m_PluginModel->shiftPluginsPriority(sourceRows, offset);
+
+ return true;
+}
+
+bool PluginListView::toggleSelectionState()
+{
+ if (m_PluginModel == nullptr) {
+ return false;
+ }
+
+ const auto sourceRows =
+ indexViewToModel(selectionModel()->selectedRows(), m_PluginModel, false);
+
+ m_PluginModel->toggleState(sourceRows);
+
+ return true;
+}
+
+QModelIndex PluginListView::indexViewToModel(const QModelIndex& index,
+ const QAbstractItemModel* model) const
+{
+ if (index.model() == model) {
+ return index;
+ } else if (const auto* const proxy =
+ qobject_cast<const QAbstractProxyModel*>(index.model())) {
+ return indexViewToModel(proxy->mapToSource(index), model);
+ } else {
+ return QModelIndex();
+ }
+}
+
+QModelIndexList PluginListView::indexViewToModel(const QModelIndexList& indices,
+ const QAbstractItemModel* model,
+ bool includeChildren) const
+{
+ QModelIndexList result;
+
+ for (const auto& idx : indices) {
+ const auto modelIdx = indexViewToModel(idx, model);
+ if (modelIdx.isValid()) {
+ result.append(modelIdx);
+ }
+
+ if (includeChildren) {
+ for (int row = 0, count = idx.model()->rowCount(idx); row < count; ++row) {
+ const auto childIdx = idx.model()->index(row, 0, idx);
+ const auto modelChildIdx = indexViewToModel(childIdx, model);
+ if (modelChildIdx.isValid()) {
+ result.append(modelChildIdx);
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+void PluginListView::onGroupRenameRequested(const QModelIndex& index,
+ const QString& name)
+{
+ if (!index.model()->hasChildren(index)) {
+ return;
+ }
+
+ QModelIndexList sourceRows;
+ for (int row = 0, count = index.model()->rowCount(index); row < count; ++row) {
+ const auto childIndex = index.model()->index(row, 0, index);
+ sourceRows.append(indexViewToModel(childIndex, m_PluginModel));
+ }
+
+ const auto persistentIndex = QPersistentModelIndex(index.model()->index(0, 0, index));
+ const bool expanded = isExpanded(index);
+
+ m_PluginModel->setGroup(sourceRows, name);
+
+ const auto newIndex = persistentIndex.parent();
+ const auto newRight = newIndex.siblingAtColumn(index.model()->columnCount() - 1);
+ setExpanded(newIndex, expanded);
+ selectionModel()->select(QItemSelection(newIndex, newRight),
+ QItemSelectionModel::ClearAndSelect);
+ selectionModel()->setCurrentIndex(newIndex, QItemSelectionModel::Current);
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListView.h b/libs/installer_bsplugins/src/BSPluginList/PluginListView.h
new file mode 100644
index 0000000..5302b86
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListView.h
@@ -0,0 +1,76 @@
+#ifndef BSPLUGINLIST_PLUGINLISTVIEW_H
+#define BSPLUGINLIST_PLUGINLISTVIEW_H
+
+#include <QSet>
+#include <QTreeView>
+
+namespace BSPluginList
+{
+
+struct MarkerInfos
+{
+ QSet<uint> overriding;
+ QSet<uint> overridden;
+ QSet<uint> overwritingAux;
+ QSet<uint> overwrittenAux;
+ QSet<uint> highlight;
+};
+
+class PluginListModel;
+class PluginSortFilterProxyModel;
+
+class PluginListView final : public QTreeView
+{
+ Q_OBJECT
+
+public:
+ explicit PluginListView(QWidget* parent = nullptr);
+
+ void setup();
+
+ void setModel(QAbstractItemModel* model) override;
+ QRect visualRect(const QModelIndex& index) const override;
+
+ [[nodiscard]] QColor markerColor(const QModelIndex& index) const;
+ [[nodiscard]] uint fileFlags(const QModelIndex& index) const;
+ [[nodiscard]] uint conflictFlags(const QModelIndex& index) const;
+
+public slots:
+ void setHighlightedOrigins(const QStringList& origins);
+ void clearOverwriteMarkers();
+ void updateOverwriteMarkers();
+
+signals:
+ void openOriginExplorer(const QModelIndex& index);
+
+protected:
+ bool event(QEvent* event) override;
+ void dragMoveEvent(QDragMoveEvent* event) override;
+ void paintEvent(QPaintEvent* event) override;
+
+ bool moveSelection(int key);
+ bool toggleSelectionState();
+
+private:
+ friend class PluginListContextMenu;
+
+ QModelIndex indexViewToModel(const QModelIndex& index,
+ const QAbstractItemModel* model) const;
+
+ QModelIndexList indexViewToModel(const QModelIndexList& indices,
+ const QAbstractItemModel* model,
+ bool includeChildren = true) const;
+
+private slots:
+ void onGroupRenameRequested(const QModelIndex& index, const QString& name);
+
+private:
+ bool m_FirstPaint = true;
+ MarkerInfos m_Markers;
+ PluginListModel* m_PluginModel;
+ PluginSortFilterProxyModel* m_SortProxy;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTVIEW_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h b/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h
new file mode 100644
index 0000000..f6359f0
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginListViewMarkingScrollBar.h
@@ -0,0 +1,29 @@
+#ifndef BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H
+#define BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H
+
+#include "GUI/ViewMarkingScrollBar.h"
+#include "PluginListView.h"
+
+namespace BSPluginList
+{
+
+class PluginListViewMarkingScrollBar : public GUI::ViewMarkingScrollBar
+{
+public:
+ explicit PluginListViewMarkingScrollBar(PluginListView* view)
+ : GUI::ViewMarkingScrollBar(view, PluginListModel::ScrollMarkRole)
+ {}
+
+ [[nodiscard]] QColor color(const QModelIndex& index) const override
+ {
+ auto color = static_cast<PluginListView*>(m_View)->markerColor(index);
+ if (!color.isValid()) {
+ color = ViewMarkingScrollBar::color(index);
+ }
+ return color;
+ }
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINLISTVIEWMARKINGSCROLLBAR_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp
new file mode 100644
index 0000000..29de392
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.cpp
@@ -0,0 +1,122 @@
+#include "PluginSortFilterProxyModel.h"
+#include "PluginListModel.h"
+
+#include <algorithm>
+
+namespace BSPluginList
+{
+
+void PluginSortFilterProxyModel::hideForceEnabledFiles(bool doHide)
+{
+ m_HideForceEnabledFiles = doHide;
+ invalidateRowsFilter();
+}
+
+bool PluginSortFilterProxyModel::filterMatchesPlugin(const QString& plugin) const
+{
+ if (m_CurrentFilter.isEmpty()) {
+ return true;
+ }
+
+ QString filterCopy = QString(m_CurrentFilter);
+ filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";");
+
+ const auto ORList = QStringTokenizer(filterCopy, u';', Qt::SkipEmptyParts);
+
+ return std::ranges::any_of(ORList, [&plugin](auto&& ORSegment) {
+ const auto ANDkeywords = QStringTokenizer(ORSegment, u' ', Qt::SkipEmptyParts);
+
+ return std::ranges::all_of(ANDkeywords, [&plugin](auto&& currentKeyword) {
+ return plugin.contains(currentKeyword, Qt::CaseInsensitive);
+ });
+ });
+}
+
+bool PluginSortFilterProxyModel::canDropMimeData(const QMimeData* data,
+ Qt::DropAction action, int row,
+ int column,
+ const QModelIndex& parent) const
+{
+ if (sortColumn() != PluginListModel::COL_PRIORITY) {
+ return false;
+ }
+
+ if (sortOrder() == Qt::DescendingOrder) {
+ --row;
+ }
+
+ const QModelIndex proxyIndex = index(row, column, parent);
+ const QModelIndex sourceIndex = mapToSource(proxyIndex);
+ return sourceModel()->canDropMimeData(data, action, sourceIndex.row(),
+ sourceIndex.column(), sourceIndex.parent());
+}
+
+bool PluginSortFilterProxyModel::dropMimeData(const QMimeData* data,
+ Qt::DropAction action, int row,
+ int column, const QModelIndex& parent)
+{
+ if (sortColumn() != PluginListModel::COL_PRIORITY) {
+ return false;
+ }
+
+ if (sortOrder() == Qt::DescendingOrder) {
+ --row;
+ }
+
+ const QModelIndex proxyIndex = index(row, column, parent);
+ const QModelIndex sourceIndex = mapToSource(proxyIndex);
+ return sourceModel()->dropMimeData(data, action, sourceIndex.row(),
+ sourceIndex.column(), sourceIndex.parent());
+}
+
+bool PluginSortFilterProxyModel::filterAcceptsRow(
+ int source_row, const QModelIndex& source_parent) const
+{
+ const auto source_index = sourceModel()->index(source_row, 0, source_parent);
+ const auto plugin =
+ source_index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>();
+ if (m_HideForceEnabledFiles && plugin &&
+ (plugin->forceLoaded() || plugin->forceEnabled())) {
+ return false;
+ }
+
+ return filterMatchesPlugin(sourceModel()->data(source_index).toString());
+}
+
+bool PluginSortFilterProxyModel::lessThan(const QModelIndex& source_left,
+ const QModelIndex& source_right) const
+{
+ switch (source_left.column()) {
+ case PluginListModel::COL_CONFLICTS: {
+ const uint lhs = source_left.data(PluginListModel::ConflictsIconRole).toUInt();
+ const uint rhs = source_right.data(PluginListModel::ConflictsIconRole).toUInt();
+ return lhs < rhs;
+ }
+ case PluginListModel::COL_FLAGS: {
+ const uint lhs = source_left.data(PluginListModel::FlagsIconRole).toUInt();
+ const uint rhs = source_right.data(PluginListModel::FlagsIconRole).toUInt();
+ if (std::popcount(lhs) != std::popcount(rhs)) {
+ return std::popcount(lhs) < std::popcount(rhs);
+ } else {
+ for (uint i = 0; i < sizeof(lhs) * 8; ++i) {
+ const uint lhsBit = (lhs & (1U << i));
+ const uint rhsBit = (rhs & (1U << i));
+ if (lhsBit != rhsBit) {
+ return lhsBit < rhsBit;
+ }
+ }
+ return false;
+ }
+ }
+ }
+
+ return QSortFilterProxyModel::lessThan(source_left, source_right);
+}
+
+void PluginSortFilterProxyModel::updateFilter(const QString& filter)
+{
+ m_CurrentFilter = filter;
+ invalidateRowsFilter();
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h
new file mode 100644
index 0000000..e0cc49c
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginSortFilterProxyModel.h
@@ -0,0 +1,40 @@
+#ifndef BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H
+#define BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H
+
+#include <QSortFilterProxyModel>
+
+namespace BSPluginList
+{
+
+class PluginSortFilterProxyModel : public QSortFilterProxyModel
+{
+ Q_OBJECT
+
+public:
+ void hideForceEnabledFiles(bool doHide);
+
+ [[nodiscard]] bool filterMatchesPlugin(const QString& plugin) const;
+
+ bool canDropMimeData(const QMimeData* data, Qt::DropAction action, int row,
+ int column, const QModelIndex& parent) const override;
+ bool dropMimeData(const QMimeData* data, Qt::DropAction action, int row, int column,
+ const QModelIndex& parent) override;
+
+ bool filterAcceptsRow(int source_row,
+ const QModelIndex& source_parent) const override;
+
+protected:
+ bool lessThan(const QModelIndex& source_left,
+ const QModelIndex& source_right) const override;
+
+public slots:
+ void updateFilter(const QString& filter);
+
+private:
+ QString m_CurrentFilter;
+ bool m_HideForceEnabledFiles = false;
+};
+
+} // namespace BSPluginList
+
+#endif // BSPLUGINLIST_PLUGINSORTFILTERPROXYMODEL_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp
new file mode 100644
index 0000000..a73725f
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.cpp
@@ -0,0 +1,833 @@
+#include "PluginsWidget.h"
+
+#include "BSPluginInfo/PluginInfoDialog.h"
+#include "GUI/MessageDialog.h"
+#include "GUI/SelectionDialog.h"
+#include "MOPlugin/Settings.h"
+#include "MOTools/Loot.h"
+#include "PluginListContextMenu.h"
+#include "PluginSortFilterProxyModel.h"
+#include "ui_pluginswidget.h"
+
+#include <game_features/gameplugins.h>
+#include <game_features/igamefeatures.h>
+
+#include <boost/range/adaptor/reversed.hpp>
+
+#include <QApplication>
+#include <QCryptographicHash>
+#include <QMenu>
+#include <QMessageBox>
+#include <QStandardPaths>
+
+using namespace Qt::Literals::StringLiterals;
+
+namespace BSPluginList
+{
+
+PluginsWidget::PluginsWidget(MOBase::IOrganizer* organizer,
+ IPanelInterface* panelInterface, QWidget* parent)
+ : QWidget(parent), ui{new Ui_PluginsWidget()}, m_PanelInterface{panelInterface},
+ m_Organizer{organizer}
+{
+ ui->setupUi(this);
+
+ m_PluginList = new TESData::PluginList(organizer);
+ m_PluginListModel = new PluginListModel(m_PluginList);
+ m_SortProxy = new PluginSortFilterProxyModel();
+ m_SortProxy->setSourceModel(m_PluginListModel);
+ m_GroupProxy = new PluginGroupProxyModel(organizer);
+ m_GroupProxy->setSourceModel(m_SortProxy);
+ ui->pluginList->setModel(m_GroupProxy);
+ ui->pluginList->setup();
+ ui->pluginList->sortByColumn(PluginListModel::COL_PRIORITY, Qt::AscendingOrder);
+ ui->pluginList->expandAll();
+ optionsMenu = listOptionsMenu();
+ ui->listOptionsBtn->setMenu(optionsMenu);
+
+ // LOOT integration stripped for Fluorine port — sort button is useless.
+ ui->sortButton->setVisible(false);
+
+ // monitor main window for close event
+ topLevelWidget()->installEventFilter(this);
+
+ restoreState();
+
+ connect(m_PluginList, &TESData::PluginList::pluginsListChanged, this,
+ &PluginsWidget::updatePluginCount);
+
+ connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, ui->pluginList,
+ &PluginListView::updateOverwriteMarkers);
+ connect(m_PluginListModel, &PluginListModel::pluginOrderChanged, ui->pluginList,
+ &PluginListView::updateOverwriteMarkers);
+ connect(m_PluginListModel, &QAbstractItemModel::modelReset, ui->pluginList,
+ &PluginListView::clearOverwriteMarkers);
+
+ connect(m_PluginListModel, &PluginListModel::pluginStatesChanged, this,
+ &PluginsWidget::updatePluginCount);
+
+ connect(m_GroupProxy, &QAbstractItemModel::modelReset, [this]() {
+ ui->pluginList->scrollToTop();
+ });
+
+ connect(ui->pluginList, &QTreeView::collapsed, this,
+ &PluginsWidget::onGroupCollapsed);
+ connect(ui->pluginList, &QTreeView::expanded, this, &PluginsWidget::onGroupExpanded);
+ connect(ui->pluginList->selectionModel(), &QItemSelectionModel::selectionChanged,
+ this, &PluginsWidget::onSelectionChanged);
+
+ panelInterface->onPanelActivated(
+ std::bind_front(&PluginsWidget::onPanelActivated, this));
+ panelInterface->onSelectedOriginsChanged(
+ std::bind_front(&PluginsWidget::onSelectedOriginsChanged, this));
+
+ organizer->onAboutToRun(std::bind_front(&PluginsWidget::onAboutToRun, this));
+ organizer->onFinishedRun(std::bind_front(&PluginsWidget::onFinishedRun, this));
+
+ organizer->modList()->onModStateChanged(
+ std::bind_front(&PluginsWidget::onModStateChanged, this));
+
+ Settings::instance()->onSettingChanged(
+ std::bind_front(&PluginsWidget::onSettingChanged, this));
+
+ synchronizePluginLists(organizer);
+ updatePluginCount();
+}
+
+PluginsWidget::~PluginsWidget() noexcept
+{
+ delete ui;
+ delete optionsMenu;
+
+ delete m_PluginList;
+ delete m_PluginListModel;
+ delete m_SortProxy;
+ delete m_GroupProxy;
+}
+
+void PluginsWidget::updatePluginCount()
+{
+ int activeMasterCount = 0;
+ int activeMediumMasterCount = 0;
+ int activeLightMasterCount = 0;
+ int activeRegularCount = 0;
+ int masterCount = 0;
+ int mediumMasterCount = 0;
+ int lightMasterCount = 0;
+ int regularCount = 0;
+ int activeVisibleCount = 0;
+
+ const auto gameFeatures = m_Organizer->gameFeatures();
+ const auto tesSupport =
+ gameFeatures ? gameFeatures->gameFeature<MOBase::GamePlugins>() : nullptr;
+
+ const bool lightPluginsAreSupported =
+ tesSupport && tesSupport->lightPluginsAreSupported();
+ const bool mediumPluginsAreSupported =
+ tesSupport && tesSupport->mediumPluginsAreSupported();
+
+ for (int i = 0, count = m_PluginListModel->rowCount(); i < count; ++i) {
+ const auto index = m_PluginListModel->index(i, 0);
+ const auto id = index.data(PluginListModel::IndexRole).toInt();
+ const auto info = m_PluginList->getPlugin(id);
+
+ if (!info)
+ continue;
+
+ const bool active = info->enabled() || info->isAlwaysEnabled();
+ const bool visible = m_SortProxy->filterAcceptsRow(index.row(), index.parent());
+ if (info->isMediumFile()) {
+ ++mediumMasterCount;
+ activeMediumMasterCount += active ? 1 : 0;
+ activeVisibleCount += visible && active ? 1 : 0;
+ } else if (info->isSmallFile()) {
+ ++lightMasterCount;
+ activeLightMasterCount += active ? 1 : 0;
+ activeVisibleCount += visible && active ? 1 : 0;
+ } else if (info->isMasterFile()) {
+ ++masterCount;
+ activeMasterCount += active ? 1 : 0;
+ activeVisibleCount += visible && active ? 1 : 0;
+ } else {
+ ++regularCount;
+ activeRegularCount += active ? 1 : 0;
+ activeVisibleCount += visible && active ? 1 : 0;
+ }
+ }
+
+ const int activeCount = activeMasterCount + activeMediumMasterCount +
+ activeLightMasterCount + activeRegularCount;
+ const int totalCount =
+ masterCount + mediumMasterCount + lightMasterCount + regularCount;
+
+ ui->activePluginsCounter->display(activeVisibleCount);
+
+ QString toolTip;
+ toolTip.reserve(575);
+ toolTip += uR"(<table cellspacing="6">)"_s
+ uR"(<tr><th>%1</th><th>%2</th><th>%3</th></tr>)"_s.arg(tr("Type"))
+ .arg(tr("Active"), -12)
+ .arg(tr("Total"));
+
+ const QString row = uR"(<tr><td>%1:</td><td align=right>%2 </td>)"_s
+ uR"(<td align=right>%3</td></tr>)"_s;
+
+ toolTip += row.arg(tr("All plugins")).arg(activeCount).arg(totalCount);
+ toolTip += row.arg(tr("ESMs")).arg(activeMasterCount).arg(masterCount);
+ toolTip += row.arg(tr("ESPs")).arg(activeRegularCount).arg(regularCount);
+ toolTip += row.arg(tr("ESMs+ESPs"))
+ .arg(activeMasterCount + activeRegularCount)
+ .arg(masterCount + regularCount);
+ if (mediumPluginsAreSupported)
+ toolTip += row.arg(tr("ESHs")).arg(activeMediumMasterCount).arg(mediumMasterCount);
+ if (lightPluginsAreSupported)
+ toolTip += row.arg(tr("ESLs")).arg(activeLightMasterCount).arg(lightMasterCount);
+ toolTip += uR"(</table>)"_s;
+
+ ui->activePluginsCounter->setToolTip(toolTip);
+}
+
+void PluginsWidget::on_espFilterEdit_textChanged(const QString& filter)
+{
+ m_SortProxy->updateFilter(filter);
+
+ if (!filter.isEmpty()) {
+ setStyleSheet("QTreeView { border: 2px ridge #f00; }");
+ ui->activePluginsCounter->setStyleSheet("QLCDNumber { border: 2px ridge #f00; }");
+ } else {
+ setStyleSheet("");
+ ui->activePluginsCounter->setStyleSheet("");
+ }
+ updatePluginCount();
+}
+
+bool PluginsWidget::eventFilter(QObject* watched, QEvent* event)
+{
+ if (event->type() == QEvent::Close) {
+ saveState();
+ m_PluginList->writePluginLists();
+ }
+
+ return QWidget::eventFilter(watched, event);
+}
+
+void PluginsWidget::changeEvent(QEvent* event)
+{
+ QWidget::changeEvent(event);
+ switch (event->type()) {
+ case QEvent::LanguageChange:
+ ui->retranslateUi(this);
+ break;
+ default:
+ break;
+ }
+}
+
+void PluginsWidget::onGroupCollapsed(const QModelIndex& index)
+{
+ if (ui->pluginList->selectionModel()->isSelected(index)) {
+ onSelectionChanged();
+ }
+}
+
+void PluginsWidget::onGroupExpanded(const QModelIndex& index)
+{
+ if (ui->pluginList->selectionModel()->isSelected(index)) {
+ onSelectionChanged();
+ }
+}
+
+void PluginsWidget::onSelectionChanged()
+{
+ QList<QString> selectedFiles;
+ std::function<void(const QModelIndex&)> addFiles;
+ addFiles = [&](const QModelIndex& index) {
+ if (index.model()->hasChildren(index)) {
+ if (ui->pluginList->isExpanded(index)) {
+ return;
+ }
+
+ for (int i = 0, count = index.model()->rowCount(index); i < count; ++i) {
+ addFiles(index.model()->index(i, 0, index));
+ }
+ } else {
+ if (const auto info =
+ index.data(PluginListModel::InfoRole).value<const TESData::FileInfo*>()) {
+ selectedFiles.append(info->name());
+ }
+ }
+ };
+
+ for (const auto& index : ui->pluginList->selectionModel()->selectedRows()) {
+ addFiles(index);
+ }
+
+ m_PanelInterface->setSelectedFiles(selectedFiles);
+}
+
+void PluginsWidget::onPanelActivated()
+{
+ m_PluginListModel->refresh();
+}
+
+void PluginsWidget::onSelectedOriginsChanged(const QList<QString>& origins)
+{
+ ui->pluginList->setHighlightedOrigins(origins);
+}
+
+void PluginsWidget::toggleHideForceEnabled()
+{
+ const bool doHide = toggleForceEnabled->isChecked();
+ m_SortProxy->hideForceEnabledFiles(doHide);
+ updatePluginCount();
+
+ Settings::instance()->set("hide_force_enabled", doHide);
+}
+
+void PluginsWidget::toggleIgnoreMasterConflicts()
+{
+ const bool doIgnore = toggleIgnoreMasters->isChecked();
+ Settings::instance()->set("ignore_master_conflicts", doIgnore);
+
+ m_PluginListModel->invalidateConflicts();
+}
+
+constexpr auto PATTERN_BACKUP_GLOB = R"/(.????_??_??_??_??_??)/";
+constexpr auto PATTERN_BACKUP_REGEX = R"/(\.(\d\d\d\d_\d\d_\d\d_\d\d_\d\d_\d\d))/";
+constexpr auto PATTERN_BACKUP_DATE = R"/(yyyy_MM_dd_hh_mm_ss)/";
+
+static QString queryRestore(const QString& filePath, QWidget* parent = nullptr)
+{
+ QFileInfo pluginFileInfo(filePath);
+ QString pattern = pluginFileInfo.fileName() + ".*";
+ QFileInfoList files = pluginFileInfo.absoluteDir().entryInfoList(
+ QStringList(pattern), QDir::Files, QDir::Name);
+
+ GUI::SelectionDialog dialog(QObject::tr("Choose backup to restore"), parent);
+ QRegularExpression exp(QRegularExpression::anchoredPattern(pluginFileInfo.fileName() +
+ PATTERN_BACKUP_REGEX));
+ QRegularExpression exp2(
+ QRegularExpression::anchoredPattern(pluginFileInfo.fileName() + "\\.(.*)"));
+ for (const QFileInfo& info : boost::adaptors::reverse(files)) {
+ auto match = exp.match(info.fileName());
+ auto match2 = exp2.match(info.fileName());
+ if (match.hasMatch()) {
+ QDateTime time = QDateTime::fromString(match.captured(1), PATTERN_BACKUP_DATE);
+ dialog.addChoice(time.toString(), "", match.captured(1));
+ } else if (match2.hasMatch()) {
+ dialog.addChoice(match2.captured(1), "", match2.captured(1));
+ }
+ }
+
+ if (dialog.numChoices() == 0) {
+ QMessageBox::information(parent, QObject::tr("No Backups"),
+ QObject::tr("There are no backups to restore"));
+ return QString();
+ }
+
+ if (dialog.exec() == QDialog::Accepted) {
+ return dialog.getChoiceData().toString();
+ } else {
+ return QString();
+ }
+}
+
+void PluginsWidget::displayPluginInformation(const QModelIndex& index)
+{
+ const int id = index.data(PluginListModel::IndexRole).toInt();
+ const auto fileName = m_PluginList->getPlugin(id)->name();
+ const auto parent = topLevelWidget();
+ BSPluginInfo::PluginInfoDialog dialog{m_Organizer, m_PluginList, fileName, parent};
+ dialog.exec();
+
+ const bool ignoreMasters =
+ Settings::instance()->get<bool>("ignore_master_conflicts", false);
+ toggleIgnoreMasters->setChecked(ignoreMasters);
+ m_PluginListModel->invalidateConflicts();
+}
+
+void PluginsWidget::on_pluginList_customContextMenuRequested(const QPoint& pos)
+{
+ PluginListContextMenu menu{ui->pluginList->indexAt(pos), m_PluginListModel,
+ ui->pluginList, m_Organizer->modList(), m_PluginList};
+
+ connect(&menu, &PluginListContextMenu::openModInformation,
+ [this](const QModelIndex& index) {
+ const int id = index.data(PluginListModel::IndexRole).toInt();
+ const auto fileName = m_PluginList->getPlugin(id)->name();
+ m_PanelInterface->displayOriginInformation(fileName);
+ });
+
+ connect(&menu, &PluginListContextMenu::openPluginInformation, this,
+ &PluginsWidget::displayPluginInformation);
+
+ const QPoint p = ui->pluginList->viewport()->mapToGlobal(pos);
+ menu.exec(p);
+}
+
+void PluginsWidget::on_pluginList_doubleClicked(const QModelIndex& index)
+{
+ bool ok;
+ const int id = index.data(PluginListModel::IndexRole).toInt(&ok);
+ if (ok) {
+ Qt::KeyboardModifiers modifiers = QApplication::queryKeyboardModifiers();
+ if (modifiers.testFlag(Qt::ControlModifier)) {
+ const auto origin = m_PluginList->getOriginName(id);
+ const auto modInfo = m_Organizer->modList()->getMod(origin);
+
+ if (modInfo) {
+ MOBase::shell::Explore(modInfo->absolutePath());
+ }
+ } else {
+ displayPluginInformation(index);
+ }
+ } else if (ui->pluginList->model()->hasChildren(index)) {
+ ui->pluginList->setExpanded(index, !ui->pluginList->isExpanded(index));
+ }
+}
+
+void PluginsWidget::on_pluginList_openOriginExplorer(const QModelIndex& index)
+{
+ const int id = index.data(PluginListModel::IndexRole).toInt();
+ const auto origin = m_PluginList->getOriginName(id);
+ const auto modInfo = m_Organizer->modList()->getMod(origin);
+
+ if (modInfo == nullptr) {
+ return;
+ }
+
+ MOBase::shell::Explore(modInfo->absolutePath());
+}
+
+void PluginsWidget::on_sortButton_clicked()
+{
+ // LOOT integration stripped for Fluorine port.
+ QMessageBox::information(
+ topLevelWidget(), tr("Sorting plugins"),
+ tr("LOOT integration is not available in this build."));
+}
+
+static bool tryRestore(const QString& filePath, const QString& identifier,
+ bool required, QWidget* parent = nullptr)
+{
+ const auto backupName = filePath + "." + identifier;
+ if (required || QFileInfo::exists(backupName)) {
+ return MOBase::shellCopy(backupName, filePath, true, parent);
+ } else {
+ return !QFileInfo::exists(filePath) || MOBase::shellDeleteQuiet(filePath, parent);
+ }
+}
+
+void PluginsWidget::on_restoreButton_clicked()
+{
+ const auto app = this->topLevelWidget();
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt"));
+
+ QString choice = queryRestore(pluginsName, app);
+ if (!choice.isEmpty()) {
+ const auto groupsName =
+ QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt"));
+ const auto loadOrderName =
+ QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt"));
+
+ if (!tryRestore(pluginsName, choice, true, app) ||
+ !tryRestore(loadOrderName, choice, true, app) ||
+ !tryRestore(groupsName, choice, false, app)) {
+ const auto e = ::GetLastError();
+
+ QMessageBox::critical(
+ this, tr("Restore failed"),
+ tr("Failed to restore the backup. Errorcode: %1")
+ .arg(QString::fromStdWString(MOBase::formatSystemMessage(e))));
+ }
+ m_PluginListModel->invalidate();
+ }
+}
+
+static bool createBackup(const QString& filePath, const QString& identifier,
+ QWidget* parent = nullptr)
+{
+ QString outPath = filePath + "." + identifier;
+ if (MOBase::shellCopy(QStringList(filePath), QStringList(outPath), parent)) {
+ QFileInfo fileInfo(filePath);
+ MOBase::removeOldFiles(fileInfo.absolutePath(),
+ fileInfo.fileName() + PATTERN_BACKUP_GLOB, 10, QDir::Name);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+static bool createBackup(const QString& filePath, const QDateTime& time,
+ QWidget* parent = nullptr)
+{
+ return createBackup(filePath, time.toString(PATTERN_BACKUP_DATE), parent);
+}
+
+void PluginsWidget::on_saveButton_clicked()
+{
+ m_PluginList->writePluginLists();
+
+ const auto app = this->topLevelWidget();
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt"));
+ const auto groupsName =
+ QDir::cleanPath(profilePath.absoluteFilePath("plugingroups.txt"));
+ const auto loadOrderName =
+ QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt"));
+ const auto lockedOrderName =
+ QDir::cleanPath(profilePath.absoluteFilePath("lockedorder.txt"));
+
+ const QDateTime now = QDateTime::currentDateTime();
+
+ if (createBackup(pluginsName, now, app) && createBackup(loadOrderName, now, app) &&
+ createBackup(groupsName, now, app) && createBackup(lockedOrderName, now, app)) {
+ GUI::MessageDialog::showMessage(tr("Backup of load order created"), app);
+ }
+}
+
+QMenu* PluginsWidget::listOptionsMenu()
+{
+ QMenu* const menu = new QMenu(this);
+ toggleForceEnabled = menu->addAction(tr("Hide force-enabled files"), this,
+ &PluginsWidget::toggleHideForceEnabled);
+ toggleForceEnabled->setCheckable(true);
+
+ toggleIgnoreMasters = menu->addAction(tr("Ignore conflicts with masters"), this,
+ &PluginsWidget::toggleIgnoreMasterConflicts);
+ toggleIgnoreMasters->setCheckable(true);
+
+ menu->addSeparator();
+
+ menu->addAction(tr("Collapse all"), [this]() {
+ ui->pluginList->collapseAll();
+ ui->pluginList->scrollToTop();
+ });
+ menu->addAction(tr("Expand all"), [this]() {
+ ui->pluginList->expandAll();
+ ui->pluginList->scrollToTop();
+ });
+
+ menu->addSeparator();
+
+ menu->addAction(tr("Enable all"), [this]() {
+ if (QMessageBox::question(topLevelWidget(), tr("Confirm"),
+ tr("Really enable all plugins?"),
+ QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
+ m_PluginListModel->setEnabledAll(true);
+ }
+ });
+ menu->addAction(tr("Disable all"), [this]() {
+ if (QMessageBox::question(topLevelWidget(), tr("Confirm"),
+ tr("Really disable all plugins?"),
+ QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
+ m_PluginListModel->setEnabledAll(false);
+ }
+ });
+
+ return menu;
+}
+
+void PluginsWidget::saveState()
+{
+ auto* const settings = Settings::instance();
+ settings->saveState(ui->pluginList->header());
+ // Fluorine port: saveTreeExpandState recurses the model, which on window
+ // close is being torn down by the time the close event fires → SIGSEGV
+ // inside the visitRows traversal. Column widths above still save fine.
+ // Tree expansion state is a minor UX loss; not worth the crash.
+}
+
+void PluginsWidget::restoreState()
+{
+ const auto* const settings = Settings::instance();
+ settings->restoreState(ui->pluginList->header());
+ settings->restoreTreeExpandState(ui->pluginList);
+
+ const bool doHide = settings->get<bool>("hide_force_enabled", false);
+ toggleForceEnabled->setChecked(doHide);
+ toggleHideForceEnabled();
+
+ const bool doIgnore = settings->get<bool>("ignore_master_conflicts", false);
+ toggleIgnoreMasters->setChecked(doIgnore);
+ toggleIgnoreMasterConflicts();
+}
+
+static bool containsPlugin(const MOBase::IModInterface* mod, const MOBase::IPluginGame* game)
+{
+ const auto fileTree = mod ? mod->fileTree() : nullptr;
+ if (!fileTree)
+ return false;
+ std::shared_ptr<const MOBase::IFileTree> searchDir;
+ if (!game->modDataDirectory().isEmpty()) {
+ searchDir = fileTree->findDirectory(game->modDataDirectory());
+ } else {
+ searchDir = fileTree;
+ }
+ if (searchDir) {
+ return std::ranges::any_of(*searchDir, [&](auto&& entry) {
+ if (!entry)
+ return false;
+ const QString filename = entry->name();
+ return filename.endsWith(u".esp"_s, Qt::CaseInsensitive) ||
+ filename.endsWith(u".esm"_s, Qt::CaseInsensitive) ||
+ filename.endsWith(u".esl"_s, Qt::CaseInsensitive);
+ });
+ }
+ return false;
+}
+
+void PluginsWidget::onModStateChanged(
+ const std::map<QString, MOBase::IModList::ModStates>& mods)
+{
+ // HACK: the virtual file tree won't update until the next refresh, so keep track of
+ // any mods that might be newly activated
+ const auto modList = m_Organizer->modList();
+ if (!modList)
+ return;
+
+ for (const auto& [modName, modState] : mods) {
+ const auto mod = modList->getMod(modName);
+ if (containsPlugin(mod, m_Organizer->managedGame())) {
+ m_PluginList->notifyPendingState(modName, modState);
+ }
+ }
+ m_PluginListModel->refresh();
+}
+
+bool PluginsWidget::onAboutToRun([[maybe_unused]] const QString& binary)
+{
+ m_PluginList->writePluginLists();
+
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt"));
+ const auto loadOrderName =
+ QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt"));
+ const auto parent = this->topLevelWidget();
+
+ if (QFileInfo::exists(pluginsName + ".snapshot")) {
+ MOBase::shellDeleteQuiet(pluginsName + ".snapshot", parent);
+ }
+
+ if (QFileInfo::exists(loadOrderName + ".snapshot")) {
+ MOBase::shellDeleteQuiet(loadOrderName + ".snapshot", parent);
+ }
+
+ if (QFileInfo(binary).fileName().compare("lootcli.exe") != 0) {
+ createBackup(pluginsName, "snapshot", parent);
+ createBackup(loadOrderName, "snapshot", parent);
+ m_IsRunningApp = true;
+ }
+
+ return true;
+}
+
+void PluginsWidget::onFinishedRun(const QString& binary,
+ [[maybe_unused]] unsigned int exitCode)
+{
+ const auto binaryName = QFileInfo(binary).fileName();
+ if (binaryName.compare("lootcli.exe", Qt::CaseInsensitive) == 0) {
+ return;
+ }
+
+ // queue up behind the vanilla callbacks which might not have run yet, so we can react
+ // after loadorder.txt changes
+ m_Organizer->onNextRefresh([=, this]() {
+ m_PluginList->refresh();
+ checkLoadOrderChanged(binaryName);
+ m_IsRunningApp = false;
+ m_ExternalStatesChanged = false;
+ });
+}
+
+void PluginsWidget::onSettingChanged(const QString& key,
+ [[maybe_unused]] const QVariant& oldValue,
+ const QVariant& newValue)
+{
+ if (key == u"enable_sort_button"_s) {
+ // Sort button permanently hidden — no LOOT integration.
+ ui->sortButton->setVisible(false);
+ }
+}
+
+static QByteArray hashFile(const QString& filePath)
+{
+ QCryptographicHash hash{QCryptographicHash::Sha1};
+ QFile file{filePath};
+ if (file.open(QIODevice::ReadOnly)) {
+ hash.addData(file.readAll());
+ } else {
+ return ""_ba;
+ }
+
+ return hash.result();
+}
+
+// Return a normalized signature of a plugins.txt / loadorder.txt so the load-
+// order-changed check can tell a cosmetic rewrite (case fix, line-ending flip,
+// trailing newline, BOM, stray blank line) apart from an actual reorder or add/
+// remove. On a case-sensitive filesystem MO2 often rewrites loadorder.txt
+// during post-run refresh to match the on-disk case of each plugin, which
+// would otherwise fire the warning on every launch.
+static QStringList normalizedPluginList(const QString& filePath)
+{
+ QFile file{filePath};
+ if (!file.open(QIODevice::ReadOnly)) {
+ return {};
+ }
+
+ QStringList out;
+ const QByteArray raw = file.readAll();
+ for (const QByteArray& rawLine : raw.split('\n')) {
+ QString line = QString::fromUtf8(rawLine).trimmed();
+ if (line.isEmpty() || line.startsWith('#')) {
+ continue;
+ }
+ // plugins.txt may prefix active entries with '*' — strip it so a re-sort
+ // that flips active state order isn't treated as a load-order edit here.
+ if (line.startsWith('*')) {
+ line = line.mid(1);
+ }
+ out.append(line.toLower());
+ }
+ return out;
+}
+
+void PluginsWidget::checkLoadOrderChanged(const QString& binaryName)
+{
+ const auto profilePath = QDir(m_Organizer->profilePath());
+ const auto pluginsName = QDir::cleanPath(profilePath.absoluteFilePath("plugins.txt"));
+ const auto loadOrderName =
+ QDir::cleanPath(profilePath.absoluteFilePath("loadorder.txt"));
+ const auto parent = this->topLevelWidget();
+
+ const auto pluginsSnapshot = pluginsName + ".snapshot";
+ const auto loadOrderSnapshot = loadOrderName + ".snapshot";
+
+ const auto pluginsFile = QFileInfo(pluginsName);
+ const auto loadOrderFile = QFileInfo(loadOrderName);
+
+ if (!QFileInfo(loadOrderSnapshot).exists())
+ return;
+
+ const bool enableWarning = Settings::instance()->externalChangeWarning();
+
+ // Compare normalized plugin name lists instead of raw byte hashes: MO2's
+ // post-run refresh rewrites loadorder.txt with filesystem-correct case,
+ // which flips the sha1 even when the actual order/contents didn't change.
+ const bool loadOrderChanged =
+ normalizedPluginList(loadOrderName) != normalizedPluginList(loadOrderSnapshot);
+ const bool pluginsChanged =
+ normalizedPluginList(pluginsName) != normalizedPluginList(pluginsSnapshot);
+
+ // we just refreshed and rewrote loadorder.txt if plugins.txt changed
+ if (enableWarning &&
+ (m_ExternalStatesChanged || loadOrderChanged || pluginsChanged)) {
+
+ if (false) {
+ // LOOT integration removed for Fluorine port.
+ } else {
+ const auto response = QMessageBox::warning(
+ parent, tr("Load order changed"),
+ tr("Load order was changed while running %1. Keep changes?").arg(binaryName),
+ QMessageBox::Yes | QMessageBox::No);
+
+ if (response == QMessageBox::No) {
+ if (!tryRestore(pluginsName, "snapshot", true, parent) ||
+ !tryRestore(loadOrderName, "snapshot", true, parent)) {
+ const auto e = ::GetLastError();
+
+ QMessageBox::critical(
+ this, tr("Restore failed"),
+ tr("Failed to restore the backup. Errorcode: %1")
+ .arg(QString::fromStdWString(MOBase::formatSystemMessage(e))));
+
+ return;
+ }
+ }
+ }
+ }
+
+ MOBase::shellDeleteQuiet(pluginsSnapshot, parent);
+ MOBase::shellDeleteQuiet(loadOrderSnapshot, parent);
+ m_PluginListModel->invalidate();
+}
+
+void PluginsWidget::importLootGroups()
+{
+ // LOOT integration removed for Fluorine port.
+}
+
+void PluginsWidget::synchronizePluginLists(MOBase::IOrganizer* organizer)
+{
+ MOBase::IPluginList* const ipluginlist = organizer->pluginList();
+ if (ipluginlist == nullptr || ipluginlist == m_PluginList) {
+ return;
+ }
+
+ std::function<void()> startRefresh = [this] {
+ m_OrganizerRefreshing = true;
+ m_PluginList->flushPendingStates();
+ // if we just finished running an application, we want the vanilla plugin list to
+ // finish reading and rewriting the load order files so that we don't end up
+ // ignoring the change
+ if (!m_IsRunningApp) {
+ // Fluorine port: non-invalidating refresh. Upstream uses invalidate()
+ // which re-parses every plugin through the TES reader on each organizer
+ // refresh — over FUSE VFS with 100+ plugins that freezes the GUI for
+ // seconds on every mod enable/disable. Incremental refresh only parses
+ // new plugins; cached ones stay.
+ m_PluginListModel->refresh();
+ }
+ };
+
+ organizer->onNextRefresh(startRefresh, false);
+
+ ipluginlist->onRefreshed([this, organizer, startRefresh]() {
+ if (m_OrganizerRefreshing) {
+ m_OrganizerRefreshing = false;
+ organizer->onNextRefresh(startRefresh, false);
+ }
+ });
+
+ ipluginlist->onPluginMoved(
+ [this](const QString& name, int oldPriority, int newPriority) {
+ if (m_OrganizerRefreshing)
+ return;
+ m_PluginListModel->movePlugin(name, oldPriority, newPriority);
+ });
+
+ ipluginlist->onPluginStateChanged(
+ [this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) {
+ if (m_OrganizerRefreshing)
+ return;
+ m_PluginListModel->changePluginStates(infos);
+ });
+
+ m_PluginList->onPluginMoved([=, this](const QString& name,
+ [[maybe_unused]] int oldPriority,
+ int newPriority) {
+ if (m_PluginList->isRefreshing())
+ return;
+
+ ipluginlist->setPriority(name, newPriority);
+ });
+
+ m_PluginList->onPluginStateChanged(
+ [=, this](const std::map<QString, MOBase::IPluginList::PluginStates>& infos) {
+ if (m_IsRunningApp) {
+ m_ExternalStatesChanged = true;
+ }
+
+ if (m_PluginList->isRefreshing() || infos.empty())
+ return;
+
+ for (const auto& [name, state] : infos) {
+ m_PanelInterface->setPluginState(name,
+ state == MOBase::IPluginList::STATE_ACTIVE);
+ }
+ });
+}
+
+} // namespace BSPluginList
diff --git a/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h
new file mode 100644
index 0000000..e44fc7e
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/PluginsWidget.h
@@ -0,0 +1,103 @@
+#ifndef BSPLUGINLIST_PLUGINSWIDGET_H
+#define BSPLUGINLIST_PLUGINSWIDGET_H
+
+#include "MOPlugin/IPanelInterface.h"
+#include "PluginGroupProxyModel.h"
+#include "PluginListModel.h"
+#include "PluginSortFilterProxyModel.h"
+#include "TESData/PluginList.h"
+
+#include <QSortFilterProxyModel>
+#include <QWidget>
+
+class Ui_PluginsWidget;
+
+namespace BSPluginList
+{
+
+class PluginsWidget final : public QWidget
+{
+ Q_OBJECT
+
+public:
+ PluginsWidget(MOBase::IOrganizer* organizer, IPanelInterface* panelInterface,
+ QWidget* parent = nullptr);
+
+ PluginsWidget(const PluginsWidget&) = delete;
+ PluginsWidget(PluginsWidget&&) = delete;
+
+ ~PluginsWidget() noexcept;
+
+ PluginsWidget& operator=(const PluginsWidget&) = delete;
+ PluginsWidget& operator=(PluginsWidget&&) = delete;
+
+ [[nodiscard]] TESData::PluginList* getPluginList() { return m_PluginList; }
+
+ bool eventFilter(QObject* watched, QEvent* event) override;
+
+public slots:
+ void updatePluginCount();
+
+protected:
+ void changeEvent(QEvent* event) override;
+
+private slots:
+ void onGroupCollapsed(const QModelIndex& index);
+ void onGroupExpanded(const QModelIndex& index);
+ void onSelectionChanged();
+
+ void onPanelActivated();
+
+ void onSelectedOriginsChanged(const QList<QString>& origins);
+
+ void toggleHideForceEnabled();
+ void toggleIgnoreMasterConflicts();
+ void displayPluginInformation(const QModelIndex& index);
+
+ void on_pluginList_customContextMenuRequested(const QPoint& pos);
+ void on_pluginList_doubleClicked(const QModelIndex& index);
+ void on_pluginList_openOriginExplorer(const QModelIndex& index);
+ void on_espFilterEdit_textChanged(const QString& filter);
+ void on_sortButton_clicked();
+ void on_restoreButton_clicked();
+ void on_saveButton_clicked();
+
+private:
+ [[nodiscard]] QMenu* listOptionsMenu();
+ void saveState();
+ void restoreState();
+
+ void onModStateChanged(const std::map<QString, MOBase::IModList::ModStates>& mods);
+ bool onAboutToRun(const QString& binary);
+ void onFinishedRun(const QString& binary, unsigned int exitCode);
+ void onSettingChanged(const QString& key, const QVariant& oldValue,
+ const QVariant& newValue);
+ void checkLoadOrderChanged(const QString& binaryName);
+ void importLootGroups();
+
+ // HACK: Attempt to keep our custom plugin list synchronized with the built-in panel
+ void synchronizePluginLists(MOBase::IOrganizer* organizer);
+
+ Ui_PluginsWidget* ui;
+
+ QMenu* optionsMenu = nullptr;
+ QAction* toggleForceEnabled = nullptr;
+ QAction* toggleIgnoreMasters = nullptr;
+
+ TESData::PluginList* m_PluginList = nullptr;
+ PluginListModel* m_PluginListModel = nullptr;
+ PluginSortFilterProxyModel* m_SortProxy = nullptr;
+ PluginGroupProxyModel* m_GroupProxy = nullptr;
+ IPanelInterface* m_PanelInterface;
+
+ MOBase::IOrganizer* m_Organizer;
+
+ bool m_DidUpdateMasterList = false;
+ bool m_OrganizerRefreshing = false;
+ bool m_IsRunningApp = false;
+ bool m_ExternalStatesChanged = false;
+};
+
+} // namespace BSPluginList
+
+#endif // TESDATA_PLUGINSWIDGET_H
diff --git a/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui b/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui
new file mode 100644
index 0000000..ea87e57
--- /dev/null
+++ b/libs/installer_bsplugins/src/BSPluginList/pluginswidget.ui
@@ -0,0 +1,279 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>PluginsWidget</class>
+ <widget class="QWidget" name="PluginsWidget">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>313</width>
+ <height>332</height>
+ </rect>
+ </property>
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="windowTitle">
+ <string>Plugins</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <property name="spacing">
+ <number>2</number>
+ </property>
+ <property name="topMargin">
+ <number>6</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <property name="spacing">
+ <number>6</number>
+ </property>
+ <item>
+ <widget class="QPushButton" name="sortButton">
+ <property name="toolTip">
+ <string>Sort the plugins using LOOT.</string>
+ </property>
+ <property name="whatsThis">
+ <string>Sort the plugins using LOOT.</string>
+ </property>
+ <property name="text">
+ <string>Sort</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../modorganizer/src/resources.qrc">
+ <normaloff>:/MO/gui/sort</normaloff>:/MO/gui/sort</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="listOptionsBtn">
+ <property name="maximumSize">
+ <size>
+ <width>16777215</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="toolTip">
+ <string>Open list options...</string>
+ </property>
+ <property name="whatsThis">
+ <string>Refresh list. This is usually not necessary unless you modified data outside the program.</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../modorganizer/src/resources.qrc">
+ <normaloff>:/MO/gui/settings</normaloff>:/MO/gui/settings</iconset>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>16</width>
+ <height>16</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="restoreButton">
+ <property name="toolTip">
+ <string>Restore a backup.</string>
+ </property>
+ <property name="whatsThis">
+ <string>Restore a backup.</string>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../modorganizer/src/resources.qrc">
+ <normaloff>:/MO/gui/restore</normaloff>:/MO/gui/restore</iconset>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>16</width>
+ <height>16</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="saveButton">
+ <property name="toolTip">
+ <string>Create a backup.</string>
+ </property>
+ <property name="whatsThis">
+ <string>Create a backup.</string>
+ </property>
+ <property name="text">
+ <string notr="true"/>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../modorganizer/src/resources.qrc">
+ <normaloff>:/MO/gui/backup</normaloff>:/MO/gui/backup</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="activePluginsLabel">
+ <property name="text">
+ <string>Active:</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLCDNumber" name="activePluginsCounter">
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>26</height>
+ </size>
+ </property>
+ <property name="whatsThis">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;This provides statistics about the plugin list. The total number of active plugins is normally displayed. Other statistics may be accessed with the tooltip of this counter.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="frameShadow">
+ <enum>QFrame::Sunken</enum>
+ </property>
+ <property name="digitCount">
+ <number>4</number>
+ </property>
+ <property name="segmentStyle">
+ <enum>QLCDNumber::Flat</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="BSPluginList::PluginListView" name="pluginList">
+ <property name="minimumSize">
+ <size>
+ <width>250</width>
+ <height>250</height>
+ </size>
+ </property>
+ <property name="contextMenuPolicy">
+ <enum>Qt::CustomContextMenu</enum>
+ </property>
+ <property name="toolTip">
+ <string>List of available esp/esm files.</string>
+ </property>
+ <property name="whatsThis">
+ <string>List of available esp/esm files.</string>
+ </property>
+ <property name="editTriggers">
+ <set>QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked</set>
+ </property>
+ <property name="dragEnabled">
+ <bool>true</bool>
+ </property>
+ <property name="dragDropOverwriteMode">
+ <bool>false</bool>
+ </property>
+ <property name="dragDropMode">
+ <enum>QAbstractItemView::InternalMove</enum>
+ </property>
+ <property name="defaultDropAction">
+ <enum>Qt::MoveAction</enum>
+ </property>
+ <property name="alternatingRowColors">
+ <bool>true</bool>
+ </property>
+ <property name="selectionMode">
+ <enum>QAbstractItemView::ExtendedSelection</enum>
+ </property>
+ <property name="selectionBehavior">
+ <enum>QAbstractItemView::SelectRows</enum>
+ </property>
+ <property name="indentation">
+ <number>20</number>
+ </property>
+ <property name="uniformRowHeights">
+ <bool>true</bool>
+ </property>
+ <property name="itemsExpandable">
+ <bool>true</bool>
+ </property>
+ <property name="sortingEnabled">
+ <bool>true</bool>
+ </property>
+ <property name="expandsOnDoubleClick">
+ <bool>false</bool>
+ </property>
+ <attribute name="headerStretchLastSection">
+ <bool>false</bool>
+ </attribute>
+ </widget>
+ </item>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <item>
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="MOBase::LineEditClear" name="espFilterEdit">
+ <property name="toolTip">
+ <string>Filter the list of plugins.</string>
+ </property>
+ <property name="whatsThis">
+ <string>Filter the list of plugins.</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="placeholderText">
+ <string>Filter</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>MOBase::LineEditClear</class>
+ <extends>QLineEdit</extends>
+ <header>lineeditclear.h</header>
+ </customwidget>
+ <customwidget>
+ <class>BSPluginList::PluginListView</class>
+ <extends>QTreeView</extends>
+ <header>BSPluginList/PluginListView.h</header>
+ </customwidget>
+ </customwidgets>
+ <resources>
+ <include location="../../../modorganizer/src/resources.qrc"/>
+ </resources>
+ <connections/>
+</ui>