diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 21:04:15 -0500 |
| commit | 3949dcfce95af4bd305f258ff5b170d7d50435f6 (patch) | |
| tree | 4c768be256c40f31794c3311f0a2c22933a6705a /libs/installer_bsplugins/src/BSPluginList | |
| parent | 892070f3dec1dc690983fd862880e37e711c2516 (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')
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><html><head/><body><p>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.</p></body></html></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> |
