From 3949dcfce95af4bd305f258ff5b170d7d50435f6 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 12 Apr 2026 21:04:15 -0500 Subject: 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) --- .../src/BSPluginList/PluginListModel.cpp | 871 +++++++++++++++++++++ 1 file changed, 871 insertions(+) create mode 100644 libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp (limited to 'libs/installer_bsplugins/src/BSPluginList/PluginListModel.cpp') 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 +#include + +#include +#include +#include +#include + +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& (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 += "" + tr("Origin") + ": " + m_Plugins->getOriginName(id); + + if (plugin->forceLoaded()) { + toolTip += "
" + + tr("This plugin can't be disabled or moved (enforced by the game).") + + ""; + } else if (plugin->forceEnabled()) { + toolTip += "
" + + tr("This plugin can't be disabled (enforced by the game).") + + ""; + } + + if (plugin->formVersion() != 0) { + // Oblivion-style plugin headers don't have a form version + toolTip += "
" + tr("Form Version") + + ": " + QString::number(plugin->formVersion()); + } + + toolTip += "
" + tr("Header Version") + + ": " + QString::number(plugin->headerVersion()); + + if (!plugin->author().isEmpty()) { + toolTip += "
" + tr("Author") + ": " + truncateString(plugin->author()); + } + + if (plugin->description().size() > 0) { + toolTip += "
" + tr("Description") + + ": " + truncateString(plugin->description()); + } + + if (plugin->enabled() && plugin->missingMasters().size() > 0) { + toolTip += "
" + tr("Missing Masters") + ": " + "" + + truncateString(QStringList(plugin->missingMasters().begin(), + plugin->missingMasters().end()) + .join(", ")) + + ""; + } + + QStringList enabledMasters; + std::ranges::remove_copy_if(plugin->masters(), std::back_inserter(enabledMasters), + [&](auto&& master) { + return plugin->missingMasters().contains(master); + }); + + if (!enabledMasters.empty()) { + toolTip += "
" + tr("Enabled Masters") + + ": " + truncateString(enabledMasters.join(", ")); + } + + if (!plugin->archives().empty()) { + QString archiveString = + plugin->archives().size() < 6 + ? truncateString( + QStringList(plugin->archives().begin(), plugin->archives().end()) + .join(", ")) + : ""; + toolTip += "
" + tr("Loads Archives") + ": " + archiveString; + } + + if (plugin->hasIni()) { + toolTip += "
" + tr("Loads INI settings") + + ": " + QFileInfo(plugin->name()).baseName() + ".ini"; + } + + if (plugin->hasNoRecords()) { + toolTip += + "

" + 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 += "
"; + } + + 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 = ""; + const QString spacing = "

"; + + if (plugin->enabled() && plugin->missingMasters().size() > 0) { + toolTip += "" + tr("Missing Masters") + ": " + "" + + truncateString(QStringList(plugin->missingMasters().begin(), + plugin->missingMasters().end()) + .join(", ")) + + "" + 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.") + + "

"; + } + + 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 += "
"; + } + 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 pred) const +{ + boost::container::flat_set 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& 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 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 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 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 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 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 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 -- cgit v1.3.1