diff --git a/launcher/minecraft/launch/ScanModFolders.cpp b/launcher/minecraft/launch/ScanModFolders.cpp index cbe1599cb..45a326e82 100644 --- a/launcher/minecraft/launch/ScanModFolders.cpp +++ b/launcher/minecraft/launch/ScanModFolders.cpp @@ -40,24 +40,31 @@ #include "minecraft/MinecraftInstance.h" #include "minecraft/mod/ModFolderModel.h" +ScanModFolders::ScanModFolders(LaunchTask* parent) : LaunchStep(parent) {} + +ScanModFolders::~ScanModFolders() +{ +} + void ScanModFolders::executeTask() { auto m_inst = m_parent->instance(); auto loaders = m_inst->loaderModList(); - connect(loaders, &ModFolderModel::updateFinished, this, &ScanModFolders::modsDone); + auto cores = m_inst->coreModList(); + auto nils = m_inst->nilModList(); + + m_modsConnection = connect(loaders, &ModFolderModel::updateFinished, this, &ScanModFolders::modsDone); if (!loaders->update()) { m_modsDone = true; } - auto cores = m_inst->coreModList(); - connect(cores, &ModFolderModel::updateFinished, this, &ScanModFolders::coreModsDone); + m_coreModsConnection = connect(cores, &ModFolderModel::updateFinished, this, &ScanModFolders::coreModsDone); if (!cores->update()) { m_coreModsDone = true; } - auto nils = m_inst->nilModList(); - connect(nils, &ModFolderModel::updateFinished, this, &ScanModFolders::nilModsDone); + m_nilModsConnection = connect(nils, &ModFolderModel::updateFinished, this, &ScanModFolders::nilModsDone); if (!nils->update()) { m_nilModsDone = true; } @@ -85,6 +92,15 @@ void ScanModFolders::nilModsDone() void ScanModFolders::checkDone() { if (m_modsDone && m_coreModsDone && m_nilModsDone) { + + // Sever the connections to the persistent ModFolderModel singletons. + // Without this, updateFinished() would re-invoke these slots the next + // time the Edit dialog opens a mod-folder page, causing emitSucceeded() + // to be called a second time on an already-Succeeded task (Task.cpp:151). + disconnect(m_modsConnection); + disconnect(m_coreModsConnection); + disconnect(m_nilModsConnection); emitSucceeded(); } } + diff --git a/launcher/minecraft/launch/ScanModFolders.h b/launcher/minecraft/launch/ScanModFolders.h index 5d9350952..401629e83 100644 --- a/launcher/minecraft/launch/ScanModFolders.h +++ b/launcher/minecraft/launch/ScanModFolders.h @@ -15,14 +15,15 @@ #pragma once +#include #include #include class ScanModFolders : public LaunchStep { Q_OBJECT public: - explicit ScanModFolders(LaunchTask* parent) : LaunchStep(parent) {}; - virtual ~ScanModFolders() {}; + explicit ScanModFolders(LaunchTask* parent); + virtual ~ScanModFolders(); virtual void executeTask() override; virtual bool canAbort() const override { return false; } @@ -38,4 +39,8 @@ class ScanModFolders : public LaunchStep { bool m_modsDone = false; bool m_nilModsDone = false; bool m_coreModsDone = false; + + QMetaObject::Connection m_modsConnection; + QMetaObject::Connection m_coreModsConnection; + QMetaObject::Connection m_nilModsConnection; }; diff --git a/launcher/ui/pages/instance/ModFolderPage.cpp b/launcher/ui/pages/instance/ModFolderPage.cpp index 99c78647c..072f16adc 100644 --- a/launcher/ui/pages/instance/ModFolderPage.cpp +++ b/launcher/ui/pages/instance/ModFolderPage.cpp @@ -46,9 +46,15 @@ #include #include #include +#include +#include #include +#include #include #include +#include +#include +#include #include #include @@ -67,9 +73,83 @@ #include "tasks/Task.h" #include "ui/dialogs/ProgressDialog.h" +ModFolderPage::~ModFolderPage() +{ + m_destructorStarted = true; + + if (m_filterWindow) { + m_filterWindow->removeEventFilter(this); + } + if (ui && ui->treeView && ui->treeView->viewport()) { + ui->treeView->viewport()->removeEventFilter(this); + } +} + ModFolderPage::ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* parent) : ExternalResourcesPage(inst, model, parent), m_model(model) { + m_profileTabBar = new QTabBar(this); + m_profileTabBar->setExpanding(false); + m_profileTabBar->setDrawBase(false); // no underline bar — matches Settings tab style + m_profileTabBar->setDocumentMode(false); + m_profileTabBar->setUsesScrollButtons(m_profileTabBar->count() > 1); + m_profileTabBar->setElideMode(Qt::ElideRight); + m_profileTabBar->setContextMenuPolicy(Qt::CustomContextMenu); + // Palette-based stylesheet: selected tab uses palette(base) to match the + // treeView background; unselected tabs use the standard button tone. + m_profileTabBar->setStyleSheet( + "QTabBar::tab {" + " padding: 4px 12px;" + " border: none;" + " background: palette(button);" + " color: palette(button-text);" + " margin-right: 1px;" + "}" + "QTabBar::tab:selected {" + " background: palette(base);" + " color: palette(text);" + "}" + "QTabBar::tab:hover:!selected {" + " background: palette(mid);" + " color: palette(button-text);" + "}"); + + // Chrome-style "+" button that sits immediately after the last tab + m_newTabButton = new QToolButton(this); + m_newTabButton->setText(QStringLiteral("+")); + m_newTabButton->setToolTip(tr("New Tab")); + m_newTabButton->setAutoRaise(true); + m_newTabButton->setFixedSize(24, 24); + + if (ui->treeView->parentWidget() && ui->treeView->parentWidget()->layout()) { + QLayout* layout = ui->treeView->parentWidget()->layout(); + if (auto grid = qobject_cast(layout)) { + // Container keeps the tab bar + "+" button left-aligned while + // remaining flush with treeView (row=1, col=1, colSpan=2). + QWidget* tabRowContainer = new QWidget(this); + auto* tabRowLayout = new QHBoxLayout(tabRowContainer); + tabRowLayout->setContentsMargins(0, 0, 0, 0); + tabRowLayout->setSpacing(0); + tabRowLayout->addWidget(m_profileTabBar, 0); // natural tab-bar width + tabRowLayout->addWidget(m_newTabButton, 0); // fixed right of last tab + tabRowLayout->addStretch(1); // remaining space left empty + grid->addWidget(tabRowContainer, 0, 1, 1, 2); + } + } + + // Install viewport filter so clicking blank space in the list clears selection. + ui->treeView->viewport()->installEventFilter(this); + + connect(m_profileTabBar, &QTabBar::currentChanged, this, [this](int index) { + ++m_profileSwitchGeneration; + applyProfileSwitch(index, m_profileSwitchGeneration); + }); + connect(m_profileTabBar, &QTabBar::customContextMenuRequested, + this, &ModFolderPage::onTabContextMenuRequested); + connect(m_newTabButton, &QToolButton::clicked, this, [this] { + onTabNewToRight(m_profileTabBar->count() - 1); + }); + ui->actionDownloadItem->setText(tr("Download Mods")); ui->actionDownloadItem->setToolTip(tr("Download mods from online mod platforms")); ui->actionDownloadItem->setEnabled(true); @@ -110,6 +190,38 @@ ModFolderPage::ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* ui->actionsToolbar->insertActionAfter(ui->actionViewHomepage, ui->actionExportMetadata); ui->actionsToolbar->insertActionAfter(ui->actionViewFolder, ui->actionViewConfigs); + + // Derive the settings key prefix from the model's directory name so that + // each page type ("mods", "coremods", "nilmods") stores profiles independently. + m_settingsPrefix = m_model->dir().dirName(); + + m_instance->settings()->getOrRegisterSetting(profileListKey(), QStringList() << "All Mods"); + QStringList profileList = m_instance->settings()->get(profileListKey()).toStringList(); + m_profileTabBar->blockSignals(true); + for (const QString& name : profileList) { + m_profileTabBar->addTab(name); + + QString key = profileKey(name); + m_instance->settings()->getOrRegisterSetting(key, QStringList()); + QStringList saved = m_instance->settings()->get(key).toStringList(); + m_profileStates[name] = QSet(saved.begin(), saved.end()); + } + m_profileTabBar->blockSignals(false); + m_profileTabBar->setUsesScrollButtons(m_profileTabBar->count() > 1); + + m_instance->settings()->getOrRegisterSetting(lastActiveIndexKey(), 0); + int savedIndex = m_instance->settings()->get(lastActiveIndexKey()).toInt(); + if (savedIndex < 0 || savedIndex >= m_profileTabBar->count()) { + savedIndex = 0; + } + m_profileTabBar->setCurrentIndex(savedIndex); + applyProfileSwitch(m_profileTabBar->currentIndex(), m_profileSwitchGeneration); + + connect(m_model, &QAbstractItemModel::dataChanged, this, [this]() { + if (!m_applyingProfile) { + saveCurrentProfileState(); + } + }); } bool ModFolderPage::shouldDisplay() const @@ -182,17 +294,38 @@ void ModFolderPage::downloadMods() void ModFolderPage::downloadDialogFinished(int result) { + if (m_downloadFlowActive) { + return; + } + m_downloadFlowActive = true; + + auto dialog = m_downloadDialog; + m_downloadDialog.clear(); + if (result != 0) { auto* tasks = new ConcurrentTask(tr("Download Mods"), APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); - connect(tasks, &Task::failed, [this, tasks](const QString& reason) { + auto terminalHandled = std::make_shared(false); + connect(tasks, &Task::failed, [this, tasks, terminalHandled](const QString& reason) { + if (*terminalHandled) { + return; + } + *terminalHandled = true; CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); tasks->deleteLater(); }); - connect(tasks, &Task::aborted, [this, tasks]() { + connect(tasks, &Task::aborted, [this, tasks, terminalHandled]() { + if (*terminalHandled) { + return; + } + *terminalHandled = true; CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); tasks->deleteLater(); }); - connect(tasks, &Task::succeeded, [this, tasks]() { + connect(tasks, &Task::succeeded, [this, tasks, terminalHandled]() { + if (*terminalHandled) { + return; + } + *terminalHandled = true; QStringList warnings = tasks->warnings(); if (warnings.count()) { CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); @@ -201,8 +334,8 @@ void ModFolderPage::downloadDialogFinished(int result) tasks->deleteLater(); }); - if (m_downloadDialog) { - for (auto& task : m_downloadDialog->getTasks()) { + if (dialog) { + for (auto& task : dialog->getTasks()) { tasks->addTask(task); } } else { @@ -215,9 +348,10 @@ void ModFolderPage::downloadDialogFinished(int result) m_model->update(); } - if (m_downloadDialog) { - m_downloadDialog->deleteLater(); + if (dialog) { + dialog->deleteLater(); } + m_downloadFlowActive = false; } void ModFolderPage::updateMods(bool includeDeps) @@ -279,15 +413,28 @@ void ModFolderPage::updateMods(bool includeDeps) if (updateDialog.exec() != 0) { auto* tasks = new ConcurrentTask("Download Mods", APPLICATION->settings()->get("NumberOfConcurrentDownloads").toInt()); - connect(tasks, &Task::failed, [this, tasks](const QString& reason) { + auto terminalHandled = std::make_shared(false); + connect(tasks, &Task::failed, [this, tasks, terminalHandled](const QString& reason) { + if (*terminalHandled) { + return; + } + *terminalHandled = true; CustomMessageBox::selectable(this, tr("Error"), reason, QMessageBox::Critical)->show(); tasks->deleteLater(); }); - connect(tasks, &Task::aborted, [this, tasks]() { + connect(tasks, &Task::aborted, [this, tasks, terminalHandled]() { + if (*terminalHandled) { + return; + } + *terminalHandled = true; CustomMessageBox::selectable(this, tr("Aborted"), tr("Download stopped by user."), QMessageBox::Information)->show(); tasks->deleteLater(); }); - connect(tasks, &Task::succeeded, [this, tasks]() { + connect(tasks, &Task::succeeded, [this, tasks, terminalHandled]() { + if (*terminalHandled) { + return; + } + *terminalHandled = true; QStringList warnings = tasks->warnings(); if (warnings.count()) { CustomMessageBox::selectable(this, tr("Warnings"), warnings.join('\n'), QMessageBox::Warning)->show(); @@ -365,7 +512,12 @@ void ModFolderPage::exportModMetadata() auto selection = m_filterModel->mapSelectionToSource(ui->treeView->selectionModel()->selection()).indexes(); auto selectedMods = m_model->selectedMods(selection); if (selectedMods.length() == 0) { - selectedMods = m_model->allMods(); + // Scope to the mods currently enabled by the active profile tab only. + for (auto* mod : m_model->allMods()) { + if (mod->enabled()) { + selectedMods.append(mod); + } + } } std::ranges::sort(selectedMods, [](const Mod* a, const Mod* b) { return a->name() < b->name(); }); @@ -373,6 +525,7 @@ void ModFolderPage::exportModMetadata() dlg.exec(); } + CoreModFolderPage::CoreModFolderPage(BaseInstance* inst, ModFolderModel* mods, QWidget* parent) : ModFolderPage(inst, mods, parent) { auto* mcInst = dynamic_cast(m_instance); @@ -382,16 +535,16 @@ CoreModFolderPage::CoreModFolderPage(BaseInstance* inst, ModFolderModel* mods, Q auto minecraftCmp = version->getComponent("net.minecraft"); if (!minecraftCmp->m_loaded) { version->reload(Net::Mode::Offline); + auto update = version->getCurrentTask(); - if (update) { + if (update && update->isRunning()) { connect(update.get(), &Task::finished, this, [this] { if (m_container) { m_container->refreshContainer(); } }); - if (!update->isRunning()) { - update->start(); - } + } else if (m_container) { + m_container->refreshContainer(); } } } @@ -445,3 +598,429 @@ inline bool ModFolderPage::handleNoModLoader() // returning true so the caller doesn't go and continue with opening it's dialog without a mod loader return true; } + +void ModFolderPage::saveCurrentProfileState() +{ + if (m_currentProfile.isEmpty()) { + return; + } + bool stillExists = false; + for (int i = 0; i < m_profileTabBar->count(); ++i) { + if (m_profileTabBar->tabText(i) == m_currentProfile) { + stillExists = true; + break; + } + } + if (!stillExists) { + return; + } + QSet enabledMods; + for (int i = 0; i < m_model->rowCount(); ++i) { + const Resource& res = m_model->at(i); + if (res.enabled()) { + enabledMods.insert(res.getOriginalFileName()); + } + } + m_profileStates[m_currentProfile] = enabledMods; + QString key = profileKey(m_currentProfile); + + m_instance->settings()->getOrRegisterSetting(key, QStringList()); + m_instance->settings()->set(key, QStringList(enabledMods.begin(), enabledMods.end())); +} + +void ModFolderPage::applyProfileSwitch(int index, int generation) { + if (m_currentProfile.isEmpty()) { + QVariant val = m_profileTabBar->property("currentProfileName"); + m_currentProfile = val.isValid() ? val.toString() : QString(); + } + + saveCurrentProfileState(); + + if (index >= 0 && index < m_profileTabBar->count()) { + QString tabName = m_profileTabBar->tabText(index); + m_currentProfile = tabName; + m_profileTabBar->setProperty("currentProfileName", tabName); + m_instance->settings()->set(lastActiveIndexKey(), index); + + QSet enabledMods; + if (m_profileStates.contains(tabName)) { + enabledMods = m_profileStates[tabName]; + } else { + QString key = profileKey(tabName); + m_instance->settings()->getOrRegisterSetting(key, QStringList()); + QStringList saved = m_instance->settings()->get(key).toStringList(); + enabledMods = QSet(saved.begin(), saved.end()); + m_profileStates[tabName] = enabledMods; + } + + QModelIndexList toEnable; + QModelIndexList toDisable; + for (int i = 0; i < m_model->rowCount(); ++i) { + const Resource& res = m_model->at(i); + QModelIndex idx = m_model->index(i, 0); + if (enabledMods.contains(res.getOriginalFileName())) { + if (!res.enabled()) { + toEnable.append(idx); + } + } else { + if (res.enabled()) { + toDisable.append(idx); + } + } + } + m_applyingProfile = true; + if (!toEnable.isEmpty()) { + m_model->setResourceEnabled(toEnable, EnableAction::ENABLE); + } + if (!toDisable.isEmpty()) { + m_model->setResourceEnabled(toDisable, EnableAction::DISABLE); + } + + // Capture generation token. If tab changes again before this update + // completes, the token will be stale and we discard the result. + int capturedGeneration = generation; + connect(m_model, &ResourceFolderModel::updateFinished, this, + [this, capturedGeneration] { + if (capturedGeneration == m_profileSwitchGeneration) { + saveCurrentProfileState(); + } + m_applyingProfile = false; + }, + Qt::SingleShotConnection); + + m_model->update(); + + } else { + m_currentProfile = QString(); + m_profileTabBar->setProperty("currentProfileName", QString()); + m_applyingProfile = false; + } +} + + +void ModFolderPage::onAddProfileClicked() { + bool ok; + QString name = QInputDialog::getText(this, tr("Add Profile"), tr("Profile Name:"), + QLineEdit::Normal, QString(), &ok); + if (ok && !name.isEmpty()) { + // Always create a blank-slate profile — no state bleeding from the current profile. + createProfile(name, QSet{}); + } +} + +void ModFolderPage::onRemoveProfileClicked() { + int index = m_profileTabBar->currentIndex(); + // Guard: index 0 is the base profile and must never be removed. + if (index > 0) { + onTabRemove(index); + } +} + +// --------------------------------------------------------------------------- +// Profile creation helpers +// --------------------------------------------------------------------------- + +void ModFolderPage::createProfile(const QString& name, const QSet& initialState, + int insertAfterIndex) +{ + // Guard: duplicate name + for (int i = 0; i < m_profileTabBar->count(); ++i) { + if (m_profileTabBar->tabText(i) == name) { + QMessageBox::warning(this, tr("Warning"), + tr("A profile with this name already exists.")); + return; + } + } + + // Persist state to memory and settings + m_profileStates[name] = initialState; + QString key = profileKey(name); + + m_instance->settings()->getOrRegisterSetting(key, QStringList()); + m_instance->settings()->set(key, QStringList(initialState.begin(), initialState.end())); + + // Insert tab at the correct position + if (insertAfterIndex >= 0 && insertAfterIndex < m_profileTabBar->count()) { + m_profileTabBar->insertTab(insertAfterIndex + 1, name); + } else { + m_profileTabBar->addTab(name); + } + m_profileTabBar->setUsesScrollButtons(m_profileTabBar->count() > 1); + + saveProfileList(); +} + +void ModFolderPage::saveProfileList() +{ + QStringList profileList; + for (int i = 0; i < m_profileTabBar->count(); ++i) { + profileList.append(m_profileTabBar->tabText(i)); + } + m_instance->settings()->set(profileListKey(), profileList); +} + +// --------------------------------------------------------------------------- +// Tab context-menu +// --------------------------------------------------------------------------- + +void ModFolderPage::onTabContextMenuRequested(const QPoint& pos) +{ + int tabIndex = m_profileTabBar->tabAt(pos); + + QMenu menu(this); + + // ── Management group ──────────────────────────────────────────────────── + auto* newToRight = menu.addAction(tr("New Tab to Right")); + int insertAfter = tabIndex >= 0 ? tabIndex : m_profileTabBar->count() - 1; + connect(newToRight, &QAction::triggered, this, [this, insertAfter] { + onTabNewToRight(insertAfter); + }); + + if (tabIndex >= 0) { + auto* duplicate = menu.addAction(tr("Duplicate")); + connect(duplicate, &QAction::triggered, this, [this, tabIndex] { + onTabDuplicate(tabIndex); + }); + + // Rename and Remove are permanently blocked for the base profile (index 0). + if (tabIndex != 0) { + auto* rename = menu.addAction(tr("Rename")); + connect(rename, &QAction::triggered, this, [this, tabIndex] { + onTabRename(tabIndex); + }); + + auto* remove = menu.addAction(tr("Remove")); + connect(remove, &QAction::triggered, this, [this, tabIndex] { + onTabRemove(tabIndex); + }); + } + + // ── Selection / bulk-action group ──────────────────────────────────── + menu.addSeparator(); + + auto* enableAll = menu.addAction(tr("Enable All")); + connect(enableAll, &QAction::triggered, this, [this, tabIndex] { + onTabEnableAll(tabIndex); + }); + + auto* disableAll = menu.addAction(tr("Disable All")); + connect(disableAll, &QAction::triggered, this, [this, tabIndex] { + onTabDisableAll(tabIndex); + }); + } + + menu.exec(m_profileTabBar->mapToGlobal(pos)); +} + +void ModFolderPage::onTabNewToRight(int sourceIndex) +{ + bool ok; + QString name = QInputDialog::getText(this, tr("New Profile"), tr("Profile Name:"), + QLineEdit::Normal, QString(), &ok); + if (ok && !name.isEmpty()) { + createProfile(name, QSet{}, sourceIndex); + } +} + +void ModFolderPage::onTabDuplicate(int sourceIndex) +{ + // Flush the live state of the currently active profile before reading source. + if (!m_currentProfile.isEmpty()) { + QSet enabledMods; + for (int i = 0; i < m_model->rowCount(); ++i) { + const Resource& res = m_model->at(i); + if (res.enabled()) { + enabledMods.insert(res.getOriginalFileName()); + } + } + m_profileStates[m_currentProfile] = enabledMods; + } + + QString sourceName = m_profileTabBar->tabText(sourceIndex); + QSet sourceState = m_profileStates.value(sourceName); + + bool ok; + QString name = QInputDialog::getText(this, tr("Duplicate Profile"), + tr("New Profile Name:"), QLineEdit::Normal, + tr("Copy of %1").arg(sourceName), &ok); + if (ok && !name.isEmpty()) { + createProfile(name, sourceState, sourceIndex); + } +} + +void ModFolderPage::onTabRename(int tabIndex) +{ + if (tabIndex == 0) return; // Guard: base profile cannot be renamed + + QString oldName = m_profileTabBar->tabText(tabIndex); + + bool ok; + QString newName = QInputDialog::getText(this, tr("Rename Profile"), + tr("New Profile Name:"), QLineEdit::Normal, + oldName, &ok); + + if (!ok || newName.isEmpty() || newName == oldName) return; + + // Validate: no duplicate names + for (int i = 0; i < m_profileTabBar->count(); ++i) { + if (m_profileTabBar->tabText(i) == newName) { + QMessageBox::warning(this, tr("Warning"), + tr("A profile named \"%1\" already exists.").arg(newName)); + return; + } + } + + // Migrate in-memory state to the new key + if (m_profileStates.contains(oldName)) { + m_profileStates[newName] = m_profileStates.take(oldName); + } + + // Migrate settings: read → reset old key → write under new key + QString oldKey = profileKey(oldName); + QString newKey = profileKey(newName); + QStringList stateData = m_instance->settings()->get(oldKey).toStringList(); + m_instance->settings()->reset(oldKey); + m_instance->settings()->getOrRegisterSetting(newKey, QStringList()); + m_instance->settings()->set(newKey, stateData); + + // Update the visible tab label + m_profileTabBar->setTabText(tabIndex, newName); + + // Keep current-profile tracking in sync + if (m_currentProfile == oldName) { + m_currentProfile = newName; + m_profileTabBar->setProperty("currentProfileName", newName); + } + + saveProfileList(); +} + +void ModFolderPage::onTabRemove(int tabIndex) +{ + if (tabIndex == 0) return; // Guard: base profile cannot be removed + + QString name = m_profileTabBar->tabText(tabIndex); + + auto response = CustomMessageBox::selectable( + this, tr("Remove Profile"), + tr("Are you sure you want to remove the profile \"%1\"?\n" + "This cannot be undone.").arg(name), + QMessageBox::Warning, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No)->exec(); + + if (response != QMessageBox::Yes) return; + + m_profileStates.remove(name); + + m_instance->settings()->reset(profileKey(name)); + + m_profileTabBar->removeTab(tabIndex); + m_profileTabBar->setUsesScrollButtons(m_profileTabBar->count() > 1); + saveProfileList(); +} + +void ModFolderPage::onTabEnableAll(int tabIndex) +{ + if (m_profileTabBar->currentIndex() != tabIndex) { + m_profileTabBar->setCurrentIndex(tabIndex); + } + applyProfileSwitch(tabIndex, m_profileSwitchGeneration); + + // Build the full index list and enable every mod in the active profile. + QModelIndexList allIndices; + allIndices.reserve(m_model->rowCount()); + for (int i = 0; i < m_model->rowCount(); ++i) { + allIndices.append(m_model->index(i, 0)); + } + if (!allIndices.isEmpty()) { + m_model->setResourceEnabled(allIndices, EnableAction::ENABLE); + m_model->update(); + } +} + +void ModFolderPage::onTabDisableAll(int tabIndex) +{ + if (m_profileTabBar->currentIndex() != tabIndex) { + m_profileTabBar->setCurrentIndex(tabIndex); + } + applyProfileSwitch(tabIndex, m_profileSwitchGeneration); + + // Build the full index list and disable every mod in the active profile. + QModelIndexList allIndices; + allIndices.reserve(m_model->rowCount()); + for (int i = 0; i < m_model->rowCount(); ++i) { + allIndices.append(m_model->index(i, 0)); + } + if (!allIndices.isEmpty()) { + m_model->setResourceEnabled(allIndices, EnableAction::DISABLE); + m_model->update(); + } +} + +bool ModFolderPage::eventFilter(QObject* obj, QEvent* ev) +{ + // Safety guard: ensure the page, its UI, and tree view are fully valid + if (!ui || !ui->treeView || !m_model) { + return ExternalResourcesPage::eventFilter(obj, ev); + } + + // When focus or clicks land outside this page (e.g. the instance sidebar), clear selection. + if (ev->type() == QEvent::MouseButtonPress || ev->type() == QEvent::FocusIn) { + if (this->isVisible()) { + QWidget* widget = qobject_cast(obj); + if (widget) { + if (!this->isAncestorOf(widget) && widget != this) { + ui->treeView->clearSelection(); + ui->treeView->setCurrentIndex(QModelIndex()); + } + } + } + } + + // Clear treeView selection when the user clicks on empty space below the + // last row, so that Export List reverts to the full active-profile scope. + if (obj == ui->treeView->viewport() && ev->type() == QEvent::MouseButtonPress) { + auto* me = static_cast(ev); + const QModelIndex idx = ui->treeView->indexAt(me->pos()); + if (!idx.isValid()) { + ui->treeView->clearSelection(); + ui->treeView->setCurrentIndex(QModelIndex()); + return true; + } + } + return ExternalResourcesPage::eventFilter(obj, ev); +} + +void ModFolderPage::mousePressEvent(QMouseEvent* event) +{ + if (ui->treeView) { + QPoint posInTreeView = ui->treeView->mapFrom(this, event->pos()); + if (!ui->treeView->rect().contains(posInTreeView)) { + ui->treeView->clearSelection(); + ui->treeView->setCurrentIndex(QModelIndex()); + } + } + ExternalResourcesPage::mousePressEvent(event); +} + +void ModFolderPage::showEvent(QShowEvent* event) +{ + ExternalResourcesPage::showEvent(event); + QWidget* w = window(); + if (w && m_filterWindow != w) { + if (m_filterWindow) { + m_filterWindow->removeEventFilter(this); + } + m_filterWindow = w; + w->installEventFilter(this); + } +} + +void ModFolderPage::hideEvent(QHideEvent* event) +{ + if (m_filterWindow) { + m_filterWindow->removeEventFilter(this); + m_filterWindow = nullptr; + } + ExternalResourcesPage::hideEvent(event); +} diff --git a/launcher/ui/pages/instance/ModFolderPage.h b/launcher/ui/pages/instance/ModFolderPage.h index 62db9fad8..f653e5ef8 100644 --- a/launcher/ui/pages/instance/ModFolderPage.h +++ b/launcher/ui/pages/instance/ModFolderPage.h @@ -39,43 +39,89 @@ #pragma once #include +#include +#include +#include +#include #include "ExternalResourcesPage.h" #include "ui/dialogs/ResourceDownloadDialog.h" class ModFolderPage : public ExternalResourcesPage { Q_OBJECT - inline bool handleNoModLoader(); + inline bool handleNoModLoader(); - public: - explicit ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* parent = nullptr); - virtual ~ModFolderPage() = default; + public: + explicit ModFolderPage(BaseInstance* inst, ModFolderModel* model, QWidget* parent = nullptr); + virtual ~ModFolderPage(); - void setFilter(const QString& filter) { m_fileSelectionFilter = filter; } + void setFilter(const QString& filter) { m_fileSelectionFilter = filter; } - virtual QString displayName() const override { return tr("Mods"); } - virtual QIcon icon() const override { return QIcon::fromTheme("loadermods"); } - virtual QString id() const override { return "mods"; } - virtual QString helpPage() const override { return "Loader-mods"; } + virtual QString displayName() const override { return tr("Mods"); } + virtual QIcon icon() const override { return QIcon::fromTheme("loadermods"); } + virtual QString id() const override { return "mods"; } + virtual QString helpPage() const override { return "Loader-mods"; } - virtual bool shouldDisplay() const override; + virtual bool shouldDisplay() const override; - public slots: - void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; + public slots: + void updateFrame(const QModelIndex& current, const QModelIndex& previous) override; - private slots: - void removeItems(const QItemSelection& selection) override; + private slots: + void removeItems(const QItemSelection& selection) override; - void downloadMods(); - void downloadDialogFinished(int result); - void updateMods(bool includeDeps = false); - void deleteModMetadata(); - void exportModMetadata(); - void changeModVersion(); + void downloadMods(); + void downloadDialogFinished(int result); + void updateMods(bool includeDeps = false); + void deleteModMetadata(); + void exportModMetadata(); + void changeModVersion(); + void onAddProfileClicked(); + void onRemoveProfileClicked(); + void saveCurrentProfileState(); - protected: - ModFolderModel* m_model; - QPointer m_downloadDialog; + // Tab context-menu slots + void onTabContextMenuRequested(const QPoint& pos); + void onTabNewToRight(int sourceIndex); + void onTabDuplicate(int sourceIndex); + void onTabRename(int tabIndex); + void onTabRemove(int tabIndex); + void onTabEnableAll(int tabIndex); + void onTabDisableAll(int tabIndex); + + private: + void applyProfileSwitch(int index, int generation); + + // Creates a new profile tab with the given initial mod-enable state. + // Pass an empty QSet for a blank slate; pass a copied QSet for duplication. + void createProfile(const QString& name, const QSet& initialState, int insertAfterIndex = -1); + // Re-serialises the current tab order into the "ModProfileList" setting. + void saveProfileList(); + + // Settings-key helpers — namespaced by model directory to prevent + // Mods / CoreMods / NilMods pages from overwriting each other's state. + QString profileListKey() const { return QStringLiteral("ModProfileList_") + m_settingsPrefix; } + QString profileKey(const QString& name) const { return m_settingsPrefix + QStringLiteral("/ModProfile_") + name; } + QString lastActiveIndexKey() const { return QStringLiteral("ModProfileLastActiveIndex_") + m_settingsPrefix; } + + protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + void mousePressEvent(QMouseEvent* event) override; + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + + ModFolderModel* m_model; + QPointer m_downloadDialog; + QPointer m_filterWindow; + bool m_downloadFlowActive = false; + QTabBar* m_profileTabBar = nullptr; + QToolButton* m_newTabButton = nullptr; ///< Chrome-style "+" button appended after last tab + QMap> m_profileStates; + QString m_currentProfile; + QString m_settingsPrefix; + bool m_destructorStarted = false; + int m_profileSwitchGeneration = 0; + bool m_applyingProfile = false; }; class CoreModFolderPage : public ModFolderPage {