aboutsummaryrefslogtreecommitdiff
path: root/libs/installer_fomod_plus/installer
diff options
context:
space:
mode:
Diffstat (limited to 'libs/installer_fomod_plus/installer')
-rw-r--r--libs/installer_fomod_plus/installer/CMakeLists.txt53
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp840
-rw-r--r--libs/installer_fomod_plus/installer/FomodInstallerWindow.h214
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp512
-rw-r--r--libs/installer_fomod_plus/installer/FomodPlusInstaller.h115
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_de.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_en.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts79
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts75
-rw-r--r--libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts4
-rw-r--r--libs/installer_fomod_plus/installer/fomodplus.json4
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp50
-rw-r--r--libs/installer_fomod_plus/installer/integration/FomodDataContent.h21
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.cpp176
-rw-r--r--libs/installer_fomod_plus/installer/lib/ConditionTester.h42
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.cpp122
-rw-r--r--libs/installer_fomod_plus/installer/lib/CrashHandler.h20
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.cpp274
-rw-r--r--libs/installer_fomod_plus/installer/lib/FileInstaller.h94
-rw-r--r--libs/installer_fomod_plus/installer/lib/FlagMap.h113
-rw-r--r--libs/installer_fomod_plus/installer/lib/Logger.h118
-rw-r--r--libs/installer_fomod_plus/installer/lib/ViewModels.h120
-rw-r--r--libs/installer_fomod_plus/installer/resources.qrc14
-rw-r--r--libs/installer_fomod_plus/installer/resources/delete.pngbin0 -> 2125 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/fomod_icon.pngbin0 -> 1782 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/image_icon.pngbin0 -> 727 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/left-chevron.pngbin0 -> 1732 bytes
-rw-r--r--libs/installer_fomod_plus/installer/resources/right-chevron.pngbin0 -> 1808 bytes
-rw-r--r--libs/installer_fomod_plus/installer/ui/ClickableWidget.h24
-rw-r--r--libs/installer_fomod_plus/installer/ui/Colors.h155
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp284
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodImageViewer.h92
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp746
-rw-r--r--libs/installer_fomod_plus/installer/ui/FomodViewModel.h154
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp120
-rw-r--r--libs/installer_fomod_plus/installer/ui/ScaleLabel.h45
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.cpp95
-rw-r--r--libs/installer_fomod_plus/installer/ui/UIHelper.h80
39 files changed, 4938 insertions, 0 deletions
diff --git a/libs/installer_fomod_plus/installer/CMakeLists.txt b/libs/installer_fomod_plus/installer/CMakeLists.txt
new file mode 100644
index 0000000..70018ff
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/CMakeLists.txt
@@ -0,0 +1,53 @@
+cmake_minimum_required(VERSION 3.16)
+include(FetchContent)
+
+file(GLOB_RECURSE INSTALLER_SOURCES CONFIGURE_DEPENDS
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.h
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.ui
+ ${CMAKE_CURRENT_SOURCE_DIR}/*.qrc
+)
+file(GLOB SHARE_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/../share/**/*.cpp")
+
+add_library(fomod_plus_installer SHARED ${INSTALLER_SOURCES} ${SHARE_SOURCES})
+
+target_include_directories(
+ fomod_plus_installer
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/../share
+ ${MO2_UIBASE_INCLUDE_DIRS}
+ ${MO2_ARCHIVE_INCLUDE_DIRS}
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+FetchContent_Declare(
+ pugixml
+ GIT_REPOSITORY https://github.com/zeux/pugixml
+ GIT_TAG v1.14
+ PATCH_COMMAND ${CMAKE_COMMAND}
+ -DPUGIXML_CMAKELISTS=<SOURCE_DIR>/CMakeLists.txt
+ -P ${CMAKE_CURRENT_LIST_DIR}/../cmake/patch_pugixml.cmake
+)
+
+FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz)
+FetchContent_MakeAvailable(pugixml json)
+
+set(project_type plugin)
+project(fomod_plus_installer)
+
+target_link_libraries(fomod_plus_installer PRIVATE mo2::uibase pugixml nlohmann_json::nlohmann_json)
+if (WIN32)
+ target_link_libraries(fomod_plus_installer PRIVATE dbghelp)
+endif ()
+mo2_configure_plugin(fomod_plus_installer NO_SOURCES WARNINGS OFF PRIVATE_DEPENDS)
+
+if (MSVC)
+ target_compile_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/Zi>)
+ target_link_options(fomod_plus_installer PRIVATE $<$<CONFIG:RelWithDebInfo>:/DEBUG>)
+ install(FILES $<TARGET_PDB_FILE:fomod_plus_installer>
+ DESTINATION ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO}
+ CONFIGURATIONS RelWithDebInfo
+ OPTIONAL)
+endif ()
+
+mo2_install_target(fomod_plus_installer)
diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp
new file mode 100644
index 0000000..e5f2e18
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.cpp
@@ -0,0 +1,840 @@
+#include "FomodInstallerWindow.h"
+
+#include "ui/FomodImageViewer.h"
+
+#include "ui/ScaleLabel.h"
+#include "ui/UIHelper.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QButtonGroup>
+#include <QCheckBox>
+#include <QComboBox>
+#include <QCompleter>
+#include <QGroupBox>
+#include <QLabel>
+#include <QRadioButton>
+#include <QScrollArea>
+#include <QScrollBar>
+#include <QSettings>
+#include <QSizePolicy>
+#include <QSplitter>
+#include <QVBoxLayout>
+#include <utility>
+
+#include "ui/FomodViewModel.h"
+
+#include <unordered_set>
+
+/**
+ *
+ * @param installer
+ * @param modName
+ * @param tree
+ * @param fomodPath
+ * @param viewModel
+ * @param fomodJson
+ * @param parent
+ */
+FomodInstallerWindow::FomodInstallerWindow(
+ FomodPlusInstaller* installer,
+ GuessedValue<QString>& modName,
+ const std::shared_ptr<IFileTree>& tree,
+ QString fomodPath,
+ const std::shared_ptr<FomodViewModel>& viewModel,
+ const nlohmann::json& fomodJson,
+ QWidget* parent): QDialog(parent),
+ mInstaller(installer),
+ mFomodPath(std::move(fomodPath)),
+ mModName(modName),
+ mTree(tree),
+ mViewModel(viewModel),
+ mFomodJson(fomodJson)
+{
+ setupUi();
+
+ mInstallStepStack = new QStackedWidget(this);
+
+ // Handle legacy FOMODs with no steps
+ if (mViewModel->getSteps().empty()) {
+ // Create a simple "Install" widget for legacy FOMODs
+ auto* legacyWidget = new QWidget(this);
+ auto* layout = new QVBoxLayout(legacyWidget);
+ auto* label = new QLabel("This mod will install all files automatically.", legacyWidget);
+ layout->addWidget(label);
+ mInstallStepStack->addWidget(legacyWidget);
+ } else {
+ updateInstallStepStack();
+ stylePreviouslySelectedOptions();
+ stylePreviouslyDeselectedOptions();
+ }
+
+ const auto containerLayout = createContainerLayout();
+ setLayout(containerLayout);
+
+ updateButtons();
+ restoreGeometryAndState();
+
+ if (!mViewModel->getSteps().empty()) {
+ populatePluginMap();
+ if (mInstaller->shouldAutoRestoreChoices()) {
+ onSelectPreviousClicked();
+ }
+ } else {
+ // For empty FOMODs, set default description
+ mDescriptionBox->setText("This mod will install all files automatically.");
+ }
+}
+
+void FomodInstallerWindow::closeEvent(QCloseEvent* event)
+{
+ saveGeometryAndState();
+ QDialog::closeEvent(event);
+}
+
+void FomodInstallerWindow::saveGeometryAndState() const
+{
+ const auto cwd = QDir::currentPath();
+ QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat);
+ settings.setValue("windowGeometry", saveGeometry());
+ settings.setValue("centerSplitState", mCenterRow->saveState());
+ settings.setValue("leftSplitState", mLeftPane->saveState());
+}
+
+void FomodInstallerWindow::restoreGeometryAndState()
+{
+ const auto cwd = QDir::currentPath();
+ const QSettings settings(cwd + "/fomod-plus-settings.ini", QSettings::IniFormat);
+ restoreGeometry(settings.value("windowGeometry").toByteArray());
+ mCenterRow->restoreState(settings.value("centerSplitState").toByteArray());
+ mLeftPane->restoreState(settings.value("leftSplitState").toByteArray());
+}
+
+void FomodInstallerWindow::populatePluginMap()
+{
+ const auto checkboxes = findChildren<QCheckBox*>();
+ const auto radioButtons = findChildren<QRadioButton*>();
+
+ for (const auto& step : mViewModel->getSteps()) {
+ for (const auto& group : step->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+
+ const auto name = createObjectName(plugin, group);
+
+ for (auto* checkbox : checkboxes) {
+ if (checkbox->objectName() == name) {
+ mPluginMap.insert({ name, { plugin, checkbox } });
+ }
+ }
+ for (auto* radioButton : radioButtons) {
+ if (radioButton->objectName() == name) {
+ mPluginMap.insert({ name, { plugin, radioButton } });
+ }
+ }
+ }
+ }
+ }
+}
+
+void FomodInstallerWindow::onNextClicked()
+{
+ // For legacy FOMODs with no steps, always install
+ if (mViewModel->getSteps().empty()) {
+ onInstallClicked();
+ return;
+ }
+
+ if (!mViewModel->isLastVisibleStep()) {
+ mViewModel->stepForward();
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateButtons();
+ updateDisplayForActivePlugin();
+ } else {
+ onInstallClicked();
+ }
+}
+
+void FomodInstallerWindow::updateCheckboxStates() const
+{
+ // PluginMap presumed to be populated
+ for (const auto& [objectName, pluginData] : mPluginMap) {
+ if (pluginData.plugin->isSelected() != pluginData.uiElement->isChecked()) {
+ const auto widgetType = pluginData.uiElement->metaObject()->className();
+ if (objectName != nullptr) {
+ logMessage(DEBUG,
+ "Updating " + objectName.toStdString() + " to state: " + (pluginData.plugin->isSelected()
+ ? "TRUE"
+ : "FALSE") + " because " + widgetType +
+ " selection state is " + (pluginData.uiElement->isChecked() ? "TRUE" : "FALSE"));
+ }
+ pluginData.uiElement->setChecked(pluginData.plugin->isSelected());
+ }
+
+ if (pluginData.plugin->isEnabled() != pluginData.uiElement->isEnabled()) {
+ logMessage(DEBUG, "[WINDOW] Changing enabled state of element " + objectName.toStdString() + " to " +
+ (pluginData.plugin->isEnabled() ? "TRUE" : "FALSE"));
+ pluginData.uiElement->setEnabled(pluginData.plugin->isEnabled());
+ }
+ }
+}
+
+void FomodInstallerWindow::onPluginToggled(const bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ logMessage(INFO,
+ "onPluginToggled called with " + plugin->getName() + " in " + group->getName() + ": " +
+ std::to_string(selected));
+ if (mViewModel->togglePlugin(group, plugin, selected)) {
+ updateCheckboxStates();
+ }
+ if (mNextInstallButton != nullptr) {
+ updateButtons();
+ }
+}
+
+void FomodInstallerWindow::onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ logMessage(INFO, "onPluginManuallyUnchecked called with " + plugin->getName());
+ mViewModel->markManuallySet(plugin);
+}
+
+void FomodInstallerWindow::onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const
+{
+ mViewModel->setActivePlugin(plugin);
+ updateDisplayForActivePlugin();
+}
+
+void FomodInstallerWindow::onBackClicked() const
+{
+ mViewModel->stepBack();
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateButtons();
+ updateDisplayForActivePlugin();
+}
+
+void FomodInstallerWindow::onInstallClicked()
+{
+ saveGeometryAndState();
+
+ logMessage(DEBUG, "Installing mod: " + mModName->toStdString());
+ mModName.update(mModNameInput->currentText(), GUESS_USER);
+ mViewModel->preinstall(mTree, mFomodPath);
+ // now the installer is available in the outer scope
+ // the outer scope should call getFileInstaller() and install there.
+ this->accept();
+}
+
+void FomodInstallerWindow::updateButtons() const
+{
+ // For legacy FOMODs with no steps, always show Install
+ if (mViewModel->getSteps().empty()) {
+ mBackButton->setEnabled(false);
+ mNextInstallButton->setText(tr("Install"));
+ return;
+ }
+
+ if (mViewModel->isFirstVisibleStep()) {
+ mBackButton->setEnabled(false);
+ } else {
+ mBackButton->setEnabled(true);
+ }
+
+ if (mViewModel->isLastVisibleStep()) {
+ mNextInstallButton->setText(tr("Install"));
+ } else {
+ mNextInstallButton->setText(tr("Next"));
+ }
+}
+
+void FomodInstallerWindow::setupUi()
+{
+ setWindowIcon(QIcon(":/fomod/hat"));
+ setWindowFlags(Qt::Window); // Allows OS-controlled resizing, including snapping
+ setMinimumSize(UiConstants::WINDOW_MIN_WIDTH, UiConstants::WINDOW_MIN_HEIGHT);
+ setWindowTitle(mModName);
+ setWindowModality(Qt::NonModal); // To allow scrolling modlist without closing the window
+}
+
+// mInstallStepStack must be initialized before calling this
+void FomodInstallerWindow::updateInstallStepStack()
+{
+ if (!mInstallStepStack) {
+ logMessage(ERR, "updateInstallStepStack called with no initialized mInstallStepStack. tf?");
+ return;
+ }
+ // Create the widgets for each step. Not sure if we need these as member variables. Try it like this for now.
+ for (const auto& steps = mViewModel->getSteps(); const auto& installStep : steps) {
+ mInstallStepStack->addWidget(createStepWidget(installStep));
+ }
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+}
+
+/*
++-------------------------------------------------------------------+
+| +----------------------------------------------------------------+|
+| | ||
+| | Metadata and Name Input ||
+| | ||
+| +----------------------------------------------------------------+|
+| +------------------------------++--------------------------------+|
+| | || ||
+| | || ||
+| | Description || ||
+| | || ||
+| | || Step/Group/Plugins ||
+| | || ||
+| +------------------------------+| ||
+| +------------------------------+| ||
+| | || ||
+| | || ||
+| | Image || ||
+| | || ||
+| | || ||
+| | || ||
+| +----------------------------------------------------------------+|
+| | ||
+| | Bottom Bar ||
+| +----------------------------------------------------------------+|
++-------------------------------------------------------------------+
+*/
+QBoxLayout* FomodInstallerWindow::createContainerLayout()
+{
+ const auto layout = new QVBoxLayout(this);
+
+ mTopRow = createTopRow();
+ mCenterRow = createCenterRow();
+ mBottomRow = createBottomRow();
+
+ layout->addWidget(mTopRow);
+ layout->addWidget(mCenterRow, 1); // stretch 1 here so the others are static size
+ layout->addWidget(mBottomRow);
+
+ if (mInstaller->shouldShowNotifications()) {
+ mNotificationsPanel = createNotificationPanel();
+ layout->addWidget(mNotificationsPanel);
+ // Set a default welcome message
+ addNotification("FOMOD Plus notification panel initialized :)", INFO);
+ }
+ return layout;
+}
+
+QSplitter* FomodInstallerWindow::createCenterRow()
+{
+ mLeftPane = createLeftPane(); // Instance var to persist the geometry
+ const auto centerRow = new QSplitter(Qt::Horizontal, this);
+ const auto rightPane = createRightPane();
+ centerRow->addWidget(mLeftPane);
+ centerRow->addWidget(rightPane);
+ centerRow->setSizes({ width() / 2, width() / 2 });
+ return centerRow;
+}
+
+QWidget* FomodInstallerWindow::createTopRow()
+{
+ const auto topRow = new QWidget(this);
+
+ auto* mainHLayout = new QHBoxLayout(topRow);
+
+ // Holds the name (label), author, version, and website
+ auto* metadataLayout = new QHBoxLayout();
+
+ // left side metadata. just the titles of the metadata
+ auto* labelsColumn = new QVBoxLayout();
+ QLabel* nameLabel = UIHelper::createLabel(tr("Name:"), topRow);
+ QLabel* authorLabel = UIHelper::createLabel(tr("Author:"), topRow);
+ QLabel* versionLabel = UIHelper::createLabel(tr("Version:"), topRow);
+ QLabel* websiteLabel = UIHelper::createLabel(tr("Website:"), topRow);
+
+ labelsColumn->addWidget(nameLabel);
+ labelsColumn->addWidget(authorLabel);
+ labelsColumn->addWidget(versionLabel);
+ labelsColumn->addWidget(websiteLabel);
+
+ UIHelper::reduceLabelPadding(labelsColumn);
+ UIHelper::setGlobalAlignment(labelsColumn, Qt::AlignTop);
+
+ // the values of the metadata MINUS the search box
+ auto* valuesColumn = new QVBoxLayout();
+ QLabel* emptyLabel = UIHelper::createLabel("", topRow);
+ QLabel* authorValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getAuthor().c_str(), topRow);
+ QLabel* versionValueLabel = UIHelper::createLabel(mViewModel->getInfoViewModel()->getVersion().c_str(), topRow);
+ QLabel* websiteValueLabel = UIHelper::createHyperlink(mViewModel->getInfoViewModel()->getWebsite().c_str(), topRow);
+
+ valuesColumn->addWidget(emptyLabel);
+ valuesColumn->addWidget(authorValueLabel);
+ valuesColumn->addWidget(versionValueLabel);
+ valuesColumn->addWidget(websiteValueLabel);
+
+ // We want these cleanup fns to be at the layout level directly containing the labels.
+ // Since we aren't recursing down the UI forever we can't just call it for mainHLayout.
+ UIHelper::reduceLabelPadding(valuesColumn);
+ UIHelper::setGlobalAlignment(valuesColumn, Qt::AlignTop);
+
+ metadataLayout->addLayout(labelsColumn);
+ metadataLayout->addLayout(valuesColumn, 1); // To push the right column close to the edge of the left.
+
+ mainHLayout->addLayout(metadataLayout, 1);
+
+ // Now make the search bar layout
+ auto* modNameComboBox = createModNameComboBox();
+ mainHLayout->addWidget(modNameComboBox, 4);
+ UIHelper::setGlobalAlignment(mainHLayout, Qt::AlignTop);
+
+ // Extra stuff
+ topRow->setLayout(mainHLayout);
+ topRow->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
+ return topRow;
+}
+
+QComboBox* FomodInstallerWindow::createModNameComboBox()
+{
+ mModNameInput = new QComboBox(this);
+ mModNameInput->setEditable(true);
+
+ mModNameInput->addItem(mModName);
+
+ for (const auto& variant : mModName.variants()) {
+ if (variant.toStdString() != mModName->toStdString()) {
+ mModNameInput->addItem(variant);
+ }
+ }
+ mModNameInput->completer()->setCaseSensitivity(Qt::CaseSensitive);
+ return mModNameInput;
+}
+
+QWidget* FomodInstallerWindow::createBottomRow()
+{
+ // In vanilla FOMOD installer, left has the Manual button, right has back, next/install, and cancel buttons
+ const auto bottomRow = new QWidget(this);
+ auto* layout = new QHBoxLayout(bottomRow);
+
+ // Manual on far left
+ mManualButton = UIHelper::createButton(tr("Manual"), bottomRow);
+ mSelectPreviousButton = UIHelper::createButton(tr("Restore Previous Choices"), bottomRow);
+ mResetChoicesButton = UIHelper::createButton(tr("Reset Choices"), bottomRow);
+
+ layout->addWidget(mManualButton);
+ layout->addWidget(mSelectPreviousButton);
+ layout->addWidget(mResetChoicesButton);
+
+ // Space to push remaining buttons right
+ layout->addStretch();
+
+ mBackButton = UIHelper::createButton(tr("Back"), bottomRow);
+ mNextInstallButton = UIHelper::createButton(tr("Next"), bottomRow);
+ mCancelButton = UIHelper::createButton(tr("Cancel"), bottomRow);
+
+ mNextInstallButton->setDefault(true);
+ mNextInstallButton->setAutoDefault(true);
+
+ connect(mManualButton, SIGNAL(clicked()), this, SLOT(onManualClicked()));
+ connect(mNextInstallButton, SIGNAL(clicked()), this, SLOT(onNextClicked()));
+ connect(mBackButton, SIGNAL(clicked()), this, SLOT(onBackClicked()));
+ connect(mCancelButton, SIGNAL(clicked()), this, SLOT(onCancelClicked()));
+ connect(mSelectPreviousButton, SIGNAL(clicked()), this, SLOT(onSelectPreviousClicked()));
+ connect(mResetChoicesButton, SIGNAL(clicked()), this, SLOT(onResetChoicesClicked()));
+ // connect(mHideImagesButton, SIGNAL(clicked()), this, SLOT(toggleImagesShown()));
+
+ layout->addWidget(mBackButton);
+ layout->addWidget(mNextInstallButton);
+ layout->addWidget(mCancelButton);
+
+ bottomRow->setLayout(layout);
+ return bottomRow;
+}
+
+QSplitter* FomodInstallerWindow::createLeftPane()
+{
+ const auto leftPane = new QSplitter(Qt::Vertical, this);
+
+ // Add description box
+ // Initialize with defaults (the first plugin's description (which defaults to the module image otherwise))
+ auto* scrollArea = new QScrollArea(leftPane);
+ scrollArea->setWidgetResizable(true);
+
+ mDescriptionBox = new QLabel("", leftPane);
+ mDescriptionBox->setTextFormat(Qt::RichText);
+ mDescriptionBox->setTextInteractionFlags(Qt::TextBrowserInteraction);
+ mDescriptionBox->setOpenExternalLinks(true);
+ mDescriptionBox->setWordWrap(true);
+ mDescriptionBox->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ mDescriptionBox->setAlignment(Qt::AlignTop | Qt::AlignLeft);
+
+ scrollArea->setWidget(mDescriptionBox);
+ leftPane->addWidget(scrollArea);
+
+ // Add image
+ // Initialize with defaults (the first plugin's image)
+ mImageLabel = new ScaleLabel(leftPane);
+ mImageLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+
+ connect(mImageLabel, &ScaleLabel::clicked, this, [this] {
+ if (!mImageLabel->hasResource()) {
+ return;
+ }
+ const auto viewer = new FomodImageViewer(this, mFomodPath, mViewModel->getActiveStep(),
+ mViewModel->getActivePlugin());
+ viewer->showMaximized();
+ });
+
+ if (!mInstaller->shouldShowImages()) {
+ mImageLabel->hide();
+ }
+
+ leftPane->addWidget(mImageLabel);
+ leftPane->setSizes({ height() / 2, height() / 2 });
+
+ updateDisplayForActivePlugin();
+
+ return leftPane;
+}
+
+QWidget* FomodInstallerWindow::createRightPane()
+{
+ const auto rightPane = new QWidget(this);
+ auto* layout = new QVBoxLayout(rightPane);
+
+ layout->addWidget(mInstallStepStack);
+
+ return rightPane;
+}
+
+QTextEdit* FomodInstallerWindow::createNotificationPanel()
+{
+ auto* panel = new QTextEdit(this);
+ panel->setReadOnly(true);
+ panel->setMaximumHeight(100); // Limit height
+ panel->setStyleSheet("font-family: monospace; font-size: 9pt;");
+
+ return panel;
+}
+
+QWidget* FomodInstallerWindow::createStepWidget(const std::shared_ptr<StepViewModel>& installStep)
+{
+ const auto stepBox = new QGroupBox(QString::fromStdString(installStep->getName()), this);
+ const auto stepBoxLayout = new QVBoxLayout(stepBox);
+
+ auto* scrollArea = new QScrollArea(stepBox);
+ scrollArea->setWidgetResizable(true);
+
+ const auto scrollAreaContent = new QWidget(scrollArea);
+ auto* scrollAreaLayout = new QVBoxLayout(scrollAreaContent);
+
+ for (const auto& group : installStep->getGroups()) {
+ const auto groupSection = renderGroup(group);
+ scrollAreaLayout->addWidget(groupSection);
+ }
+
+ scrollAreaContent->setLayout(scrollAreaLayout);
+ scrollArea->setWidget(scrollAreaContent);
+
+ stepBoxLayout->addWidget(scrollArea);
+ stepBox->setLayout(stepBoxLayout);
+ return stepBox;
+}
+
+QWidget* FomodInstallerWindow::renderGroup(const std::shared_ptr<GroupViewModel>& group)
+{
+ const auto groupBox = new QGroupBox(QString::fromStdString(group->getName()), this);
+ const auto groupBoxLayout = new QVBoxLayout(groupBox);
+
+ switch (group->getType()) {
+ case SelectAtLeastOne:
+ case SelectAny:
+ case SelectAll:
+ renderCheckboxGroup(groupBox, groupBoxLayout, group);
+ break;
+ case SelectExactlyOne:
+ case SelectAtMostOne:
+ renderSelectExactlyOne(groupBox, groupBoxLayout, group);
+ break;
+ default: ;
+ }
+
+ groupBox->setLayout(groupBoxLayout);
+ return groupBox;
+}
+
+QString FomodInstallerWindow::createObjectName(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ const std::string objectName = std::format("[{}:{}] {}-{}",
+ group->getStepIndex(), group->getOwnIndex(),
+ group->getName(), plugin->getName());
+
+ return QString::fromStdString(objectName);
+}
+
+QRadioButton* FomodInstallerWindow::createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group,
+ QWidget* parent)
+{
+ auto* radioButton = new QRadioButton(QString::fromStdString(plugin->getName()), parent);
+ radioButton->setObjectName(createObjectName(plugin, group));
+ auto* hoverFilter = new HoverEventFilter(plugin, this);
+ radioButton->installEventFilter(hoverFilter);
+ connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered);
+
+ connect(radioButton, &QRadioButton::toggled, this, [this, radioButton, group, plugin](const bool checked) {
+ logMessage(INFO,
+ "Received toggled signal for radio: " + plugin->getName() + ": " + (checked ? "TRUE" : "FALSE") +
+ " Radio is now: " + (radioButton->isChecked() ? "TRUE" : "FALSE"));
+ onPluginToggled(checked, group, plugin);
+ });
+
+ radioButton->setEnabled(plugin->isEnabled());
+ radioButton->setChecked(plugin->isSelected());
+ return radioButton;
+}
+
+QCheckBox* FomodInstallerWindow::createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent)
+{
+ auto* checkBox = new QCheckBox(QString::fromStdString(plugin->getName()), parent);
+ checkBox->setObjectName(createObjectName(plugin, group));
+
+ // Make the hover stuff work
+ auto* hoverFilter = new HoverEventFilter(plugin, this);
+ checkBox->installEventFilter(hoverFilter);
+ connect(hoverFilter, &HoverEventFilter::hovered, this, &FomodInstallerWindow::onPluginHovered);
+
+ // Install Ctrl+click event filter
+ auto* ctrlClickFilter = new CtrlClickEventFilter(plugin, group, this);
+ checkBox->installEventFilter(ctrlClickFilter);
+
+ checkBox->setEnabled(plugin->isEnabled());
+ checkBox->setChecked(plugin->isSelected());
+ connect(checkBox, &QCheckBox::clicked, this, [this, plugin](const bool checked) {
+ // Send a message to viewModel saying the user deactivated the plugin manually.
+ // This may get overridden later by automatic checking, but we'll reconcile that at JSON serialization time.
+ if (!checked) {
+ onPluginManuallyUnchecked(plugin);
+ }
+ });
+ connect(checkBox, &QCheckBox::toggled, this, [this, checkBox, group, plugin](const bool checked) {
+ logMessage(INFO,
+ "Received toggled signal for checkbox: " + plugin->getName() + ": " + (checked ? "true" : "false") +
+ " Checkbox was previously " + (checkBox->isChecked() ? "true" : "false"));
+ onPluginToggled(checked, group, plugin);
+ });
+ return checkBox;
+}
+
+void FomodInstallerWindow::renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ // This is for parity with the legacy installer. Both styles are functionally equivalent
+ // for a group size of 1, but they chose checkbox.
+ if (group->getPlugins().size() == 1) {
+ renderCheckboxGroup(parent, parentLayout, group);
+ } else {
+ renderRadioGroup(parent, parentLayout, group);
+ }
+}
+
+void FomodInstallerWindow::renderCheckboxGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ for (const auto& plugin : group->getPlugins()) {
+ auto* checkbox = createPluginCheckBox(plugin, group, parent);
+ parentLayout->addWidget(checkbox);
+ }
+}
+
+QButtonGroup* FomodInstallerWindow::renderRadioGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group)
+{
+ auto* buttonGroup = new QButtonGroup(parent);
+ buttonGroup->setExclusive(true); // Ensure only one button can be selected
+
+ for (const auto& plugin : group->getPlugins()) {
+ auto* radioButton = createPluginRadioButton(plugin, group, parent);
+ buttonGroup->addButton(radioButton);
+ parentLayout->addWidget(radioButton);
+ }
+ return buttonGroup;
+}
+
+void FomodInstallerWindow::addNotification(const QString& message, const LogLevel level) const
+{
+ if (!mNotificationsPanel) {
+ return;
+ }
+
+ const QString timestamp = QDateTime::currentDateTime().toString("hh:mm:ss");
+ const QString formattedMsg = QString("<span>[%2] [%3] %4</span>")
+ .arg(timestamp).arg(logLevelToString(level)).arg(message);
+
+ mNotificationsPanel->append(formattedMsg);
+
+ // Auto-scroll to bottom
+ QScrollBar* scrollbar = mNotificationsPanel->verticalScrollBar();
+ scrollbar->setValue(scrollbar->maximum());
+}
+
+[[deprecated]]
+void FomodInstallerWindow::toggleImagesShown() const
+{
+ logMessage(DEBUG, "Toggling image visibility");
+ mInstaller->toggleShouldShowImages();
+ if (mInstaller->shouldShowImages()) {
+ logMessage(DEBUG, "Turning images ON");
+ mImageLabel->show();
+ mHideImagesButton->setText(tr("Hide Images"));
+ } else {
+ logMessage(DEBUG, "Turning images OFF");
+ mImageLabel->hide();
+ mHideImagesButton->setText(tr("Show Images"));
+ }
+}
+
+
+// Updates the image and description field for a given plugin. Also use this on initialization of those widgets.
+void FomodInstallerWindow::updateDisplayForActivePlugin() const
+{
+ // Skip if no steps (legacy FOMOD)
+ if (mViewModel->getSteps().empty()) {
+ return;
+ }
+
+ auto plugin = mViewModel->getActivePlugin();
+ if (!plugin) {
+ const auto activeStep = mViewModel->getActiveStep();
+ if (!activeStep || activeStep->getGroups().empty() || activeStep->getGroups().front()->getPlugins().empty()) {
+ mDescriptionBox->setText(tr("Select a plugin to see its description."));
+ mImageLabel->clear();
+ return;
+ }
+ // Fall back to the first plugin in the active step when no active plugin is set.
+ plugin = activeStep->getGroups().front()->getPlugins().front();
+ mViewModel->setActivePlugin(plugin);
+ }
+
+ const QString description = formatPluginDescription(QString::fromStdString(plugin->getDescription()));
+ mDescriptionBox->setText(description);
+
+ const auto image = mViewModel->getDisplayImage();
+ if (image.empty()) {
+ mImageLabel->clear();
+ return;
+ }
+
+ const auto imagePath = UIHelper::getFullImagePath(mFomodPath, QString::fromStdString(image));
+ mImageLabel->setScalableResource(imagePath);
+}
+
+/**
+ *
+ * @param pluginSelector For now either 'plugins', or 'deselected'. The key of the member of 'groups' to iterate over.
+ * @param fn The callback for each plugin in the chosen group member.
+ */
+void FomodInstallerWindow::applyFnFromJson(const std::string& pluginSelector,
+ const std::function<void(QAbstractButton*)>& fn)
+{
+ if (mFomodJson.empty()) {
+ return;
+ }
+
+ const auto jsonSteps = mFomodJson["steps"];
+ // for each step in JSON, create a <group>-<plugin> string out of the { groups: [ { plugins... } ] } array
+ vector<std::string> selectedPlugins;
+
+ // TODO: Can groups have the same name within a step, or across steps? How do we account for that?
+ for (int stepIndex = 0; stepIndex < jsonSteps.size(); ++stepIndex) {
+ const auto& step = jsonSteps[stepIndex];
+ for (int groupIndex = 0; groupIndex < step["groups"].size(); ++groupIndex) {
+ const auto& group = step["groups"][groupIndex];
+
+ if (!group.contains(pluginSelector)) {
+ continue;
+ }
+
+ for (int pluginIndex = 0; pluginIndex < group[pluginSelector].size(); ++pluginIndex) {
+ const auto& plugin = group[pluginSelector][pluginIndex];
+
+ std::string name = std::format("[{}:{}] {}-{}",
+ stepIndex, groupIndex, group["name"].get<std::string>(), plugin.get<std::string>());
+ selectedPlugins.push_back(name);
+ }
+ }
+ }
+
+ const auto checkboxes = findChildren<QCheckBox*>();
+ const auto radioButtons = findChildren<QRadioButton*>();
+
+ for (auto* checkbox : checkboxes) {
+ for (const auto& selectedPlugin : selectedPlugins) {
+ if (checkbox->objectName().toStdString() == selectedPlugin) {
+ fn(checkbox);
+ }
+ }
+ }
+ for (auto* radio : radioButtons) {
+ for (const auto& selectedPlugin : selectedPlugins) {
+ if (radio->objectName().toStdString() == selectedPlugin) {
+ fn(radio);
+ }
+ }
+ }
+}
+
+void FomodInstallerWindow::stylePreviouslySelectedOptions()
+{
+ const auto stylesheet = getColorStyle(UiColors::ColorApplication::BACKGROUND);
+
+ const auto tooltip = "You previously selected this plugin when installing this mod.";
+
+ logMessage(INFO, "Styling previously selected choices with stylesheet " + stylesheet.toStdString(), true);
+ applyFnFromJson("plugins", [stylesheet, tooltip](QAbstractButton* button) {
+ button->setStyleSheet(stylesheet);
+ button->setToolTip(tooltip);
+ });
+}
+
+void FomodInstallerWindow::stylePreviouslyDeselectedOptions()
+{
+ const auto stylesheet = getColorStyle(UiColors::ColorApplication::BORDER);
+ const auto tooltip = "You previously unchecked this plugin when installing this mod.";
+ applyFnFromJson("deselected", [stylesheet, tooltip](QAbstractButton* button) {
+ button->setStyleSheet(stylesheet);
+ button->setToolTip(tooltip);
+ });
+}
+
+void FomodInstallerWindow::selectPreviouslySelectedOptions() const
+{
+ logMessage(INFO, "Selecting previously selected choices", true);
+ logMessage(INFO, "Existing JSON provided: " + mFomodJson.dump(4));
+ if (mFomodJson.empty()) {
+ return;
+ }
+ try {
+ mViewModel->selectFromJson(mFomodJson);
+ } catch (Exception& e) {
+ logMessage(ERR, std::string("Error selecting previously selected options: ") + e.what(), true);
+ }
+ updateCheckboxStates();
+}
+
+void FomodInstallerWindow::onResetChoicesClicked()
+{
+ logMessage(INFO, "Resetting choices to author defaults", true);
+ try {
+ mViewModel->resetToDefaults();
+ } catch (Exception& e) {
+ logMessage(ERR, std::string("Error resetting choices: ") + e.what(), true);
+ return;
+ }
+
+ // Reset the UI to show the first step
+ mInstallStepStack->setCurrentIndex(mViewModel->getCurrentStepIndex());
+ updateCheckboxStates();
+ updateButtons();
+ updateDisplayForActivePlugin();
+}
+
+QString FomodInstallerWindow::getColorStyle(const UiColors::ColorApplication color_application) const
+{
+ const auto selectedColor = mInstaller->getSelectedColor();
+ logMessage(DEBUG, "Selected color: " + selectedColor.toStdString());
+ return getStyle(selectedColor, color_application);
+}
diff --git a/libs/installer_fomod_plus/installer/FomodInstallerWindow.h b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h
new file mode 100644
index 0000000..bca2215
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodInstallerWindow.h
@@ -0,0 +1,214 @@
+#ifndef FOMODINSTALLERWINDOW_H
+#define FOMODINSTALLERWINDOW_H
+
+#include <qboxlayout.h>
+#include <qbuttongroup.h>
+#include <qcheckbox.h>
+#include <qcombobox.h>
+#include <qtextedit.h>
+
+#include "FomodPlusInstaller.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDialog>
+#include <QStackedWidget>
+#include <qradiobutton.h>
+#include <ui/ScaleLabel.h>
+
+#include "FomodInstallerWindow.h"
+#include "lib/FileInstaller.h"
+#include "ui/FomodViewModel.h"
+#include "ui/Colors.h"
+
+#include <QSplitter>
+
+using namespace MOBase;
+
+struct PluginData {
+ std::shared_ptr<PluginViewModel> plugin;
+ QAbstractButton* uiElement;
+};
+
+
+class FomodPlusInstaller;
+/**
+ * @class FomodInstallerWindow
+ * @brief This class represents a window for the FOMOD installer.
+ *
+ * The FomodInstallerWindow class is designed to handle and manage the FOMOD installation
+ * process. It integrates functionalities specific to FOMOD package installations.
+ * By inheriting from QObject, it supports signal-slot mechanisms, enabling interaction
+ * and communication with other components in the application.
+ *
+ * This class is primarily intended to provide a user interface and functionality
+ * to process and run the FOMOD installer in a structured manner.
+ */
+class FomodInstallerWindow final : public QDialog {
+ Q_OBJECT
+
+public:
+ FomodInstallerWindow(FomodPlusInstaller* installer,
+ GuessedValue<QString>& modName,
+ const std::shared_ptr<IFileTree>& tree,
+ QString fomodPath,
+ const std::shared_ptr<FomodViewModel>& viewModel,
+ const nlohmann::json& fomodJson,
+ QWidget* parent = nullptr);
+
+ void closeEvent(QCloseEvent* event) override;
+
+ void saveGeometryAndState() const;
+
+ void restoreGeometryAndState();
+
+ void populatePluginMap();
+
+
+ // So FomodPlusInstaller can check if the user wants to manually install
+ [[nodiscard]] bool isManualInstall() const
+ {
+ return mIsManualInstall;
+ }
+
+ [[nodiscard]] std::shared_ptr<FileInstaller> getFileInstaller() const { return mViewModel->getFileInstaller(); }
+
+private slots:
+ void onNextClicked();
+
+ void updateCheckboxStates() const;
+
+ void onPluginToggled(bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onPluginManuallyUnchecked(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onPluginHovered(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void onSelectPreviousClicked() const { this->selectPreviouslySelectedOptions(); }
+
+ void onResetChoicesClicked();
+
+ void onBackClicked() const;
+
+ void onCancelClicked()
+ {
+ this->saveGeometryAndState();
+ this->reject();
+ }
+
+ void onManualClicked()
+ {
+ mIsManualInstall = true;
+ this->saveGeometryAndState();
+ this->reject();
+ }
+
+ void onInstallClicked();
+
+ [[deprecated]] void toggleImagesShown() const;
+
+private:
+ Logger& log = Logger::getInstance();
+ FomodPlusInstaller* mInstaller;
+ QString mFomodPath;
+ GuessedValue<QString>& mModName;
+ std::shared_ptr<IFileTree> mTree;
+ std::shared_ptr<FomodViewModel> mViewModel;
+ bool mInitialized{ false };
+ std::unordered_map<QString, PluginData> mPluginMap;
+
+
+ // Meta
+ bool mIsManualInstall{};
+ nlohmann::json mFomodJson;
+
+ // Buttons
+ QPushButton* mNextInstallButton{};
+ QPushButton* mBackButton{};
+ QPushButton* mCancelButton{};
+ QPushButton* mManualButton{};
+ QPushButton* mSelectPreviousButton{};
+ QPushButton* mResetChoicesButton{};
+ QPushButton* mHideImagesButton{};
+
+ // Widgets
+ QComboBox* mModNameInput{};
+ QLabel* mDescriptionBox{};
+ QSplitter* mCenterRow{};
+ QSplitter* mLeftPane{};
+ QStackedWidget* mInstallStepStack{};
+ QTextEdit* mNotificationsPanel{};
+ QWidget* mBottomRow{};
+ QWidget* mTopRow{};
+ ScaleLabel* mImageLabel{};
+
+ // Fn
+ void setupUi();
+
+ void updateButtons() const;
+
+ void updateInstallStepStack();
+
+ void updateDisplayForActivePlugin() const;
+
+ void applyFnFromJson(const std::string& pluginSelector, const std::function<void(QAbstractButton*)> &fn);
+
+ void stylePreviouslySelectedOptions();
+
+ void stylePreviouslyDeselectedOptions();
+
+ void selectPreviouslySelectedOptions() const;
+
+ [[nodiscard]] QString getColorStyle(UiColors::ColorApplication color_application) const;
+
+ [[nodiscard]] QBoxLayout* createContainerLayout();
+
+ [[nodiscard]] QSplitter* createCenterRow();
+
+ [[nodiscard]] QWidget* createTopRow();
+
+ [[nodiscard]] QComboBox* createModNameComboBox();
+
+ [[nodiscard]] QWidget* createBottomRow();
+
+ [[nodiscard]] QSplitter* createLeftPane();
+
+ [[nodiscard]] QWidget* createRightPane();
+
+ [[nodiscard]] QTextEdit* createNotificationPanel();
+
+ [[nodiscard]] QWidget* createStepWidget(const std::shared_ptr<StepViewModel>& installStep);
+
+ [[nodiscard]] QWidget* renderGroup(const std::shared_ptr<GroupViewModel>& group);
+
+ static QString createObjectName(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ QRadioButton* createPluginRadioButton(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent);
+
+ QCheckBox* createPluginCheckBox(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QWidget* parent);
+
+ void renderSelectExactlyOne(QWidget* parent, QLayout* parentLayout, const std::shared_ptr<GroupViewModel>& group);
+
+ void renderCheckboxGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ QButtonGroup* renderRadioGroup(QWidget* parent, QLayout* parentLayout,
+ const std::shared_ptr<GroupViewModel>& group);
+
+ void addNotification(const QString& message, LogLevel level) const;
+
+ void logMessage(const LogLevel level, const std::string& message, const bool asNotification = true) const
+ {
+ log.logMessage(level, "[WINDOW] " + message);
+ if (asNotification) {
+ addNotification(QString::fromStdString(message), level);
+ }
+ }
+
+};
+
+
+#endif //FOMODINSTALLERWINDOW_H
diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp
new file mode 100644
index 0000000..690bdbb
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.cpp
@@ -0,0 +1,512 @@
+#include "FomodPlusInstaller.h"
+
+#include <igamefeatures.h>
+#include <iinstallationmanager.h>
+#include <iplugingame.h>
+#include <QEventLoop>
+#include <QTreeWidget>
+#include <xml/FomodInfoFile.h>
+#include <xml/ModuleConfiguration.h>
+#include <xml/XmlParseException.h>
+
+#include "FomodInstallerWindow.h"
+#include "stringutil.h"
+#include "integration/FomodDataContent.h"
+#include "ui/Colors.h"
+#include "ui/FomodViewModel.h"
+#include "lib/CrashHandler.h"
+
+#include <QMessageBox>
+#include <QSettings>
+
+using namespace Qt::Literals::StringLiterals;
+
+/*
+--------------------------------------------------------------------------------
+ Init
+--------------------------------------------------------------------------------
+*/
+#pragma region Initialization
+
+bool FomodPlusInstaller::init(IOrganizer* organizer)
+{
+ CrashHandler::initialize();
+ mOrganizer = organizer;
+ mFomodContent = make_shared<FomodDataContent>(organizer);
+ log.setLogFilePath(QDir::currentPath().toStdString() + "/logs/fomodplus.log");
+ std::cout << "QDir::currentPath(): " << QDir::currentPath().toStdString() << std::endl;
+ std::cout << "mOrganizer->basePath() : " << mOrganizer->basePath().toStdString() << std::endl;
+
+ // REMEMBER: This mFomodDB persists beyond the scope of an individual install. Do not do anything nasty to it.
+ mFomodDb = std::make_unique<FomodDB>(mOrganizer->basePath().toStdString());
+ setupUiInjection();
+ return true;
+}
+
+void FomodPlusInstaller::setupUiInjection() const
+{
+ if (shouldShowSidebarFilter()) {
+ mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false);
+ }
+
+ mOrganizer->onPluginSettingChanged([this](const QString& pluginName, const QString& key, const QVariant& oldValue, const QVariant& newValue) {
+ if (pluginName == name() && key == "show_fomod_filter") {
+ toggleFeature(newValue.toBool());
+ }
+ });
+}
+
+void FomodPlusInstaller::toggleFeature(const bool enabled) const
+{
+ if (enabled) {
+ mOrganizer->gameFeatures()->registerFeature(mFomodContent, 9999, false);
+ } else {
+ mOrganizer->gameFeatures()->unregisterFeature(mFomodContent);
+ }
+ mOrganizer->refresh();
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Settings
+--------------------------------------------------------------------------------
+*/
+#pragma region Settings
+bool FomodPlusInstaller::shouldFallbackToLegacyInstaller() const
+{
+ return mOrganizer->pluginSetting(name(), "fallback_to_legacy").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowImages() const
+{
+ return mOrganizer->pluginSetting(name(), "show_images").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowNotifications() const
+{
+ return mOrganizer->pluginSetting(name(), "show_notifications").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldShowSidebarFilter() const
+{
+ return mOrganizer->pluginSetting(name(), "show_fomod_filter").value<bool>();
+}
+
+bool FomodPlusInstaller::shouldAutoRestoreChoices() const
+{
+ return mOrganizer->pluginSetting(name(), "always_restore_choices").value<bool>();
+}
+
+bool FomodPlusInstaller::isWizardIntegrated() const
+{
+ return mOrganizer->pluginSetting(name(), "wizard_integration").value<bool>();
+}
+
+void FomodPlusInstaller::toggleShouldShowImages() const
+{
+ const bool showImages = shouldShowImages();
+ mOrganizer->setPluginSetting(name(), "show_images", !showImages);
+}
+
+QString FomodPlusInstaller::getSelectedColor() const
+{
+ const auto colorName = mOrganizer->pluginSetting(name(), "color_theme").toString();
+ const auto it = UiColors::colorStyles.find(colorName);
+ return it != UiColors::colorStyles.end() ? it->first : "Blue";
+}
+
+std::vector<std::shared_ptr<const IPluginRequirement> > FomodPlusInstaller::requirements() const
+{
+ return { Requirements::gameDependency(
+ { u"Morrowind"_s, u"Oblivion"_s, u"Fallout 3"_s, u"New Vegas"_s, u"Skyrim"_s, u"Enderal"_s,
+ u"Fallout 4"_s, u"Skyrim Special Edition"_s, u"Enderal Special Edition"_s,
+ u"Skyrim VR"_s, u"Fallout 4 VR"_s, u"Starfield"_s }) };
+}
+
+QList<PluginSetting> FomodPlusInstaller::settings() const
+{
+ return {
+ { u"fallback_to_legacy"_s, u"When hitting cancel, fall back to the legacy FOMOD installer."_s, false },
+ { u"always_restore_choices"_s, u"Restore previous choices without clicking the magic button"_s, false },
+ { u"show_images"_s, u"Show image previews and the image carousel in installer windows."_s, true },
+ { u"color_theme"_s, u"Select the color theme for the installer"_s, QString("Blue") }, // Default color name
+ { u"show_notifications"_s, u"Show the notifications panel"_s, false }, //WIP
+ { u"wizard_integration"_s, u"Integrate the installer with patch wizard."_s, true }, //WIP
+ { u"show_fomod_filter"_s, u"Show the filter in the sidebar (may break other content filters)"_s, true }
+ };
+}
+
+#pragma endregion
+
+bool FomodPlusInstaller::isArchiveSupported(std::shared_ptr<const IFileTree> tree) const
+{
+ tree = findFomodDirectory(tree);
+ if (tree != nullptr) {
+ return tree->exists(StringConstants::FomodFiles::MODULE_CONFIG.data(), FileTreeEntry::FILE);
+ }
+ return false;
+}
+
+
+std::pair<nlohmann::json, IModInterface*> FomodPlusInstaller::getExistingFomodJson(const GuessedValue<QString>& modName,
+ const int& nexusId,
+ const int& stepsInCurrentFomod) const
+{
+ logMessage(DEBUG, "FomodPlusInstaller::getExistingFomodJson - modName: " + modName->toStdString() +
+ " nexusId: " + std::to_string(nexusId));
+
+ // Need to have better mod matching based on presence of FOMOD plugin data.
+ struct ModMatch {
+ MOBase::IModInterface* mod;
+ bool hasFomodData;
+ int stepCount;
+ nlohmann::json fomodJson;
+ };
+ std::vector<ModMatch> matches;
+
+ auto parseStepCount = [](const QVariant& fomodData) -> std::pair<bool, int> {
+ try {
+ if (fomodData.isValid() && !fomodData.isNull()) {
+ if (auto json = nlohmann::json::parse(fomodData.toString().toStdString()); json.contains("steps") && json["steps"].is_array()) {
+ return { true, static_cast<int>(json["steps"].size()) };
+ }
+ }
+ } catch (...) {
+ // Ignore parsing errors
+ }
+ return { false, 0 };
+ };
+
+ // Check exact match first
+ const auto modList = mOrganizer->modList();
+ if (modList == nullptr) {
+ return {};
+ }
+
+ if (const auto exactMod = modList->getMod(modName)) {
+ const auto fomodData = exactMod->pluginSetting(name(), "fomod", 0);
+ if (auto [valid, stepCount] = parseStepCount(fomodData); valid) {
+ matches.push_back({
+ exactMod,
+ true,
+ stepCount,
+ nlohmann::json::parse(fomodData.toString().toStdString())
+ });
+ } else {
+ matches.push_back({
+ exactMod,
+ false,
+ 0,
+ nlohmann::json()
+ });
+ }
+ }
+
+ // Check all variants
+ for (const auto& variant : modName.variants()) {
+ if (const auto variantMod = modList->getMod(variant)) {
+ const auto fomodData = variantMod->pluginSetting(name(), "fomod", 0);
+ if (auto [valid, stepCount] = parseStepCount(fomodData); valid) {
+ matches.push_back({
+ variantMod,
+ true,
+ stepCount,
+ nlohmann::json::parse(fomodData.toString().toStdString())
+ });
+ } else {
+ matches.push_back({
+ variantMod,
+ false,
+ 0,
+ nlohmann::json()
+ });
+ }
+ }
+ }
+
+ // No matches found
+ if (matches.empty()) {
+ return {};
+ }
+
+ // First try to find exact step count match
+ const auto exactMatch = ranges::find_if(matches,
+ [stepsInCurrentFomod](const ModMatch& match) {
+ return match.hasFomodData && match.stepCount == stepsInCurrentFomod;
+ });
+
+ if (exactMatch != matches.end()) {
+ logMessage(DEBUG, "Found exact step count match in mod: " +
+ exactMatch->mod->name().toStdString() +
+ " with " + std::to_string(exactMatch->stepCount) + " steps");
+ return std::make_pair(exactMatch->fomodJson, exactMatch->mod);
+ }
+
+ // Find the closest step count among mods with FOMOD data
+ const auto closestMatch = ranges::min_element(matches,
+ [stepsInCurrentFomod](const ModMatch& a, const ModMatch& b) {
+ if (!a.hasFomodData || !b.hasFomodData)
+ return false;
+ // The min difference between the step counts
+ return std::abs(a.stepCount - stepsInCurrentFomod) <
+ std::abs(b.stepCount - stepsInCurrentFomod);
+ });
+
+ if (closestMatch != matches.end() && closestMatch->hasFomodData) {
+ logMessage(DEBUG, "Using closest step count match from mod: " +
+ closestMatch->mod->name().toStdString() +
+ " with " + std::to_string(closestMatch->stepCount) + " steps");
+ return std::make_pair(closestMatch->fomodJson, closestMatch->mod);
+ }
+
+ // Fallback to first mod with any FOMOD data
+ const auto anyFomod = ranges::find_if(matches,
+ [](const ModMatch& match) { return match.hasFomodData; });
+
+ if (anyFomod != matches.end()) {
+ logMessage(DEBUG, "Using first available FOMOD data from mod: " +
+ anyFomod->mod->name().toStdString());
+ return std::make_pair(anyFomod->fomodJson, anyFomod->mod);
+ }
+
+ logMessage(DEBUG, "No matching FOMOD data found");
+ return {};
+}
+
+void FomodPlusInstaller::clearPriorInstallData()
+{
+ mInstallerUsed = false;
+ mFomodJson = nullptr;
+ mFomodPath = "";
+}
+
+/**
+ *
+ * @param modName
+ * @param tree
+ * @param version
+ * @param nexusID
+ * @return
+ */
+IPluginInstaller::EInstallResult FomodPlusInstaller::install(GuessedValue<QString>& modName,
+ std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID)
+{
+ clearPriorInstallData();
+
+ logMessage(INFO, std::format("FomodPlusInstaller::install - modName: {}, version: {}, nexusID: {}",
+ modName->toStdString(),
+ version.toStdString(),
+ nexusID
+ ));
+ logMessage(INFO, std::format("FomodPlusInstaller::install - tree size: {}", tree->size()));
+
+ auto [infoFile, moduleConfigFile, filePaths] = parseFomodFiles(tree);
+
+ if (infoFile == nullptr || moduleConfigFile == nullptr) {
+ return RESULT_FAILED;
+ }
+
+ std::vector<QString> pluginPaths = {};
+ for (const auto& filePath : filePaths) {
+ if (isPluginFile(filePath)) {
+ pluginPaths.emplace_back(filePath);
+ }
+ }
+
+ const auto dbEntry = mFomodDb->getEntryFromFomod(moduleConfigFile.get(), pluginPaths, nexusID);
+
+ // create ui & pass xml classes to ui
+ auto [json, matchMod] = getExistingFomodJson(modName, nexusID, static_cast<int>(moduleConfigFile->installSteps.installSteps.size()));
+ if (matchMod != nullptr) {
+ modName.update(matchMod->name(), GUESS_USER);
+ }
+ auto fomodViewModel = FomodViewModel::create(mOrganizer, std::move(moduleConfigFile), std::move(infoFile));
+ const auto window = std::make_shared<FomodInstallerWindow>(this, modName, tree, mFomodPath, fomodViewModel, json);
+
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const QDialog::DialogCode result = showInstallerWindow(window);
+ if (result == QDialog::Accepted) {
+ // modname was updated in window
+ mInstallerUsed = true;
+ const std::shared_ptr<IFileTree> installTree = window->getFileInstaller()->install();
+ tree = installTree;
+ mFomodJson = std::make_shared<nlohmann::json>(window->getFileInstaller()->generateFomodJson());
+
+ try {
+ mFomodDb->addEntry(dbEntry);
+ mFomodDb->saveToFile();
+ } catch ([[maybe_unused]] Exception& e) {
+ logMessage(ERR, "Failed to add FomodDB entries.");
+ logMessage(ERR, e.what());
+ }
+ return RESULT_SUCCESS;
+ }
+ if (window->isManualInstall()) {
+ return RESULT_MANUALREQUESTED;
+ }
+ if (shouldFallbackToLegacyInstaller()) {
+ return RESULT_NOTATTEMPTED;
+ }
+ return RESULT_CANCELED;
+}
+
+
+/**
+ *
+ * @param tree
+ * @return
+ */
+ParsedFilesTuple FomodPlusInstaller::parseFomodFiles(const std::shared_ptr<IFileTree>& tree)
+{
+ const auto emptyResult = std::make_tuple(nullptr, nullptr, QStringList());
+
+ const auto fomodDir = findFomodDirectory(tree);
+ if (fomodDir == nullptr) {
+ logMessage(ERR, "FomodPlusInstaller::install - fomod directory not found");
+ return emptyResult;
+ }
+
+ // This is a strange place to set this value but okay for now.
+ mFomodPath = fomodDir->parent()->path();
+
+ const auto infoXML = fomodDir->find(
+ StringConstants::FomodFiles::INFO_XML.data(),
+ FileTreeEntry::FILE
+ );
+ const auto moduleConfig = fomodDir->find(
+ StringConstants::FomodFiles::MODULE_CONFIG.data(),
+ FileTreeEntry::FILE
+ );
+
+ // Extract files first.
+ vector<std::shared_ptr<const FileTreeEntry> > toExtract = {};
+ if (moduleConfig) {
+ toExtract.push_back(moduleConfig);
+ } else {
+ logMessage(ERR, "FomodPlusInstaller::install - error parsing moduleConfig.xml: Not Present");
+ return emptyResult;
+ }
+ if (infoXML) {
+ toExtract.push_back(infoXML);
+ }
+ appendImageFiles(toExtract, tree);
+ appendPluginFiles(toExtract, tree); // For patch wizard data collection
+ auto paths = manager()->extractFiles(toExtract);
+ // Normalize backslash separators from MO2's internal file tree to forward slashes for Linux
+ for (auto& p : paths) {
+ p.replace('\\', '/');
+ }
+
+ auto moduleConfiguration = std::make_unique<ModuleConfiguration>();
+ try {
+ moduleConfiguration->deserialize(paths.at(0));
+ } catch (XmlParseException& e) {
+ logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing moduleConfig.xml: {}", e.what()));
+ return emptyResult;
+ }
+
+ auto infoFile = std::make_unique<FomodInfoFile>();
+ if (infoXML) {
+ try {
+ infoFile->deserialize(paths.at(1));
+ } catch (XmlParseException& e) {
+ logMessage(ERR, std::format("FomodPlusInstaller::install - error parsing info.xml: {}", e.what()));
+ }
+ }
+
+ return std::make_tuple(std::move(infoFile), std::move(moduleConfiguration), paths);
+}
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp#L123
+void FomodPlusInstaller::appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree)
+{
+ static std::set<QString, FileNameComparator> imageSuffixes{ "png", "jpg", "jpeg", "gif", "bmp" };
+ for (auto entry : *tree) {
+ if (entry->isDir()) {
+ appendImageFiles(entries, entry->astree());
+ } else if (imageSuffixes.contains(entry->suffix())) {
+ entries.push_back(entry);
+ }
+ }
+}
+
+void FomodPlusInstaller::appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree)
+{
+ // Don't bother with this stuff if the user doesn't care about the wizard.
+ // It may slightly bloat the temp file directory if not needed.
+ if (isWizardIntegrated()) {
+ for (auto entry : *tree) {
+ if (entry->isDir()) {
+ appendPluginFiles(entries, entry->astree());
+ } else if (isPluginFile(entry->suffix())) {
+ entries.push_back(entry);
+ }
+ }
+ }
+}
+
+void FomodPlusInstaller::onInstallationStart(QString const& archive, const bool reinstallation,
+ IModInterface* currentMod)
+{
+ IPluginInstallerSimple::onInstallationStart(archive, reinstallation, currentMod);
+}
+
+void FomodPlusInstaller::onInstallationEnd(const EInstallResult result, IModInterface* newMod)
+{
+ IPluginInstallerSimple::onInstallationEnd(result, newMod);
+
+ // Update the meta.ini file with the fomod information
+ if (mFomodJson != nullptr && result == RESULT_SUCCESS && newMod != nullptr && mInstallerUsed) {
+ newMod->setPluginSetting(this->name(), "fomod", mFomodJson->dump().c_str());
+ mOrganizer->refresh();
+ }
+ clearPriorInstallData();
+}
+
+// Borrowed from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/installerfomod.cpp
+std::shared_ptr<const IFileTree> FomodPlusInstaller::findFomodDirectory(const std::shared_ptr<const IFileTree>& tree)
+{
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const auto entry = tree->find(StringConstants::FomodFiles::FOMOD_DIR.data(), FileTreeEntry::DIRECTORY);
+
+ if (entry != nullptr) {
+ return entry->astree();
+ }
+
+ if (tree->size() == 1 && tree->at(0)->isDir()) {
+ return findFomodDirectory(tree->at(0)->astree());
+ }
+ return nullptr;
+}
+
+QDialog::DialogCode FomodPlusInstaller::showInstallerWindow(const std::shared_ptr<FomodInstallerWindow>& window)
+{
+ QEventLoop loop;
+ connect(window.get(), SIGNAL(accepted()), &loop, SLOT(quit()));
+ connect(window.get(), SIGNAL(rejected()), &loop, SLOT(quit()));
+ window->show();
+ loop.exec();
+ return static_cast<QDialog::DialogCode>(window->result());
+}
+
+void showError(const Exception& e)
+{
+ const QString errorText = "Mod Name: \n" "Nexus ID: " "\n" "Exception: " + QString(e.what()) + "\n";
+
+ QMessageBox msgBox;
+ msgBox.setWindowTitle("FOMOD Plus Error :(");
+ msgBox.setTextFormat(Qt::RichText);
+ msgBox.setText("Sorry this happened. Please copy the following error and report it to me on Nexus or GitHub.\n"
+ "<pre style='background-color: #f0f0f0; padding: 10px;'>"
+ + errorText +
+ "</pre>"
+ );
+ msgBox.setIcon(QMessageBox::Critical);
+ msgBox.exec();
+
+}
diff --git a/libs/installer_fomod_plus/installer/FomodPlusInstaller.h b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h
new file mode 100644
index 0000000..494ad8e
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/FomodPlusInstaller.h
@@ -0,0 +1,115 @@
+#pragma once
+
+#include "stringutil.h"
+
+#include <iplugin.h>
+#include <iplugininstaller.h>
+#include <iplugininstallersimple.h>
+
+#include <nlohmann/json.hpp>
+#include "FomodInstallerWindow.h"
+#include "lib/Logger.h"
+#include "xml/FomodInfoFile.h"
+#include "xml/ModuleConfiguration.h"
+
+#include <QDialog>
+#include <integration/FomodDataContent.h>
+#include <FOMODData/FomodDB.h>
+
+class FomodInstallerWindow;
+
+using namespace MOBase;
+using namespace std;
+
+using ParsedFilesTuple = std::tuple<std::unique_ptr<FomodInfoFile>, std::unique_ptr<ModuleConfiguration>, QStringList>;
+
+class FomodPlusInstaller final : public IPluginInstallerSimple {
+ Q_OBJECT
+ Q_INTERFACES(MOBase::IPlugin MOBase::IPluginInstaller)
+ Q_PLUGIN_METADATA(IID "io.clearing.FomodPlus" FILE "fomodplus.json")
+
+public:
+ bool init(IOrganizer* organizer) override;
+
+ // constant values
+ [[nodiscard]] QString name() const override { return StringConstants::Plugin::NAME.data(); }
+ [[nodiscard]] QString author() const override { return StringConstants::Plugin::AUTHOR.data(); }
+ [[nodiscard]] QString description() const override { return StringConstants::Plugin::DESCRIPTION.data(); }
+ [[nodiscard]] VersionInfo version() const override { return { 1, 0, 0, VersionInfo::RELEASE_FINAL }; }
+
+ [[nodiscard]] unsigned int priority() const override
+ {
+ return 999; /* Above installer_fomod's highest priority. */
+ }
+
+ [[nodiscard]] std::vector<std::shared_ptr<const IPluginRequirement> > requirements() const override;
+
+ [[nodiscard]] bool isManualInstaller() const override { return false; }
+
+ [[nodiscard]] bool isArchiveSupported(std::shared_ptr<const IFileTree> tree) const override;
+
+ [[nodiscard]] QList<PluginSetting> settings() const override;
+
+ std::pair<nlohmann::json, IModInterface*> getExistingFomodJson(const GuessedValue<QString>& modName,
+ const int& nexusId, const int& stepsInCurrentFomod) const;
+
+ void clearPriorInstallData();
+
+ EInstallResult install(GuessedValue<QString>& modName, std::shared_ptr<IFileTree>& tree, QString& version,
+ int& nexusID) override;
+
+ void onInstallationStart(QString const& archive, bool reinstallation, IModInterface* currentMod) override;
+
+ void onInstallationEnd(EInstallResult result, IModInterface* newMod) override;
+
+ [[nodiscard]] bool shouldShowImages() const;
+
+ [[nodiscard]] bool shouldShowNotifications() const;
+ bool shouldShowSidebarFilter() const;
+
+ [[nodiscard]] bool shouldAutoRestoreChoices() const;
+
+ [[nodiscard]] bool isWizardIntegrated() const;
+
+ void toggleShouldShowImages() const;
+
+ QString getSelectedColor() const;
+
+private:
+ Logger& log = Logger::getInstance();
+ IOrganizer* mOrganizer = nullptr;
+ QString mFomodPath{};
+ std::shared_ptr<nlohmann::json> mFomodJson{ nullptr };
+ bool mInstallerUsed{ false };
+ std::shared_ptr<FomodDataContent> mFomodContent{ nullptr };
+ std::unique_ptr<FomodDB> mFomodDb;
+
+ /**
+ * @brief Retrieve the tree entry corresponding to the fomod directory.
+ *
+ * @param tree Tree to look-up the directory in.
+ *
+ * @return the entry corresponding to the fomod directory in the tree, or a null
+ * pointer if the entry was not found.
+ */
+ [[nodiscard]] static shared_ptr<const IFileTree> findFomodDirectory(const shared_ptr<const IFileTree>& tree);
+
+ [[nodiscard]] static QDialog::DialogCode showInstallerWindow(const shared_ptr<FomodInstallerWindow>& window);
+
+ [[nodiscard]] ParsedFilesTuple parseFomodFiles(const shared_ptr<IFileTree>& tree);
+
+ static void appendImageFiles(vector<shared_ptr<const FileTreeEntry> >& entries,
+ const shared_ptr<const IFileTree>& tree);
+
+ void appendPluginFiles(vector<shared_ptr<const FileTreeEntry> >& entries, const shared_ptr<const IFileTree>& tree);
+
+ void setupUiInjection() const;
+ void toggleFeature(bool enabled) const;
+
+ [[nodiscard]] bool shouldFallbackToLegacyInstaller() const;
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[INSTALLER] " + message);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_de.ts
new file mode 100644
index 0000000..1552582
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_de.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_en.ts
new file mode 100644
index 0000000..bc6d6e7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_en.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts
new file mode 100644
index 0000000..ad7bfed
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_de.ts
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="de_DE">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="194"/>
+ <source>Installieren</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="196"/>
+ <location filename="FomodInstallerWindow.cpp" line="370"/>
+ <source>Weiter</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="288"/>
+ <source>Name:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="289"/>
+ <source>Autor:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="290"/>
+ <source>Version:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="291"/>
+ <source>Webseite:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="357"/>
+ <source>Manuell</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="358"/>
+ <source>Alle Vorherige Optionen Ausw&auml;hlen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="359"/>
+ <source>Reset Choices</source>
+ <translation>Optionen Zur&uuml;cksetzen</translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="361"/>
+ <location filename="FomodInstallerWindow.cpp" line="586"/>
+ <source>Bilder Ausblenden</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="369"/>
+ <source>Zur&uuml;ck</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="371"/>
+ <source>Abbrechen</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="360"/>
+ <location filename="FomodInstallerWindow.cpp" line="590"/>
+ <source>Bilder Einblenden</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts
new file mode 100644
index 0000000..5cbe0a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_en.ts
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="en_US">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="231"/>
+ <location filename="FomodInstallerWindow.cpp" line="242"/>
+ <source>Install</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="244"/>
+ <location filename="FomodInstallerWindow.cpp" line="423"/>
+ <source>Next</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="342"/>
+ <source>Name:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="343"/>
+ <source>Author:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="344"/>
+ <source>Version:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="345"/>
+ <source>Website:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="411"/>
+ <source>Manual</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="412"/>
+ <source>Restore Previous Choices</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="413"/>
+ <source>Reset Choices</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="422"/>
+ <source>Back</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="424"/>
+ <source>Cancel</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="682"/>
+ <source>Hide Images</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="686"/>
+ <source>Show Images</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="703"/>
+ <source>Select a plugin to see its description.</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts
new file mode 100644
index 0000000..f2769e6
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_installer_zh_CN.ts
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="zh_CN">
+<context>
+ <name>FomodInstallerWindow</name>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="194"/>
+ <source>安装</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="196"/>
+ <location filename="FomodInstallerWindow.cpp" line="370"/>
+ <source>下一步</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="288"/>
+ <source>名称</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="289"/>
+ <source>作者</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="290"/>
+ <source>版本</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="291"/>
+ <source>网址</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="357"/>
+ <source>手动</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="358"/>
+ <source>选中所有上次安装的选择</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="359"/>
+ <source>Reset Choices</source>
+ <translation>重置选择</translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="361"/>
+ <location filename="FomodInstallerWindow.cpp" line="586"/>
+ <source>隐藏图片</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="369"/>
+ <source>上一步</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="371"/>
+ <source>取消</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <location filename="FomodInstallerWindow.cpp" line="360"/>
+ <location filename="FomodInstallerWindow.cpp" line="590"/>
+ <source>显示图片</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts
new file mode 100644
index 0000000..e5ca8aa
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomod_plus_zh_CN.ts
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE TS>
+<TS version="2.1" language="zh_CN">
+</TS>
diff --git a/libs/installer_fomod_plus/installer/fomodplus.json b/libs/installer_fomod_plus/installer/fomodplus.json
new file mode 100644
index 0000000..dde5a05
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/fomodplus.json
@@ -0,0 +1,4 @@
+{
+ "Id": "FOMODPlus",
+ "Version" : "1.0.0"
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp
new file mode 100644
index 0000000..ac4508d
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.cpp
@@ -0,0 +1,50 @@
+#include "FomodDataContent.h"
+
+#include <ifiletree.h>
+
+#include "stringutil.h"
+
+#include <iplugingame.h>
+
+FomodDataContent::FomodDataContent(MOBase::IOrganizer* organizer) : mOrganizer(organizer)
+{
+}
+
+std::vector<MOBase::ModDataContent::Content> FomodDataContent::getAllContents() const
+{
+ static const std::vector<Content> contents = {
+ {FomodDataContentConstants::FOMOD_CONTENT_ID, "FOMOD", ":/fomod/hat", false}
+ };
+
+ return contents;
+}
+
+// Confirmed working, no need to update
+std::vector<int> FomodDataContent::getContentsFor(const std::shared_ptr<const MOBase::IFileTree> fileTree) const
+{
+ std::vector<int> contents;
+ if (!mOrganizer || !fileTree) {
+ return contents;
+ }
+
+ const auto modList = mOrganizer->modList();
+ if (!modList) {
+ return contents;
+ }
+
+ const auto mod = modList->getMod(fileTree->name());
+ if (modHasFomodContent(mod)) {
+ contents.emplace_back(FomodDataContentConstants::FOMOD_CONTENT_ID);
+ }
+ return contents;
+}
+
+bool FomodDataContent::modHasFomodContent(const MOBase::IModInterface* mod)
+{
+ if (!mod) {
+ return false;
+ }
+ const auto pluginName = QString::fromStdString(StringConstants::Plugin::NAME.data());
+ const auto fomodMeta = mod->pluginSetting(pluginName, "fomod", 0);
+ return fomodMeta != 0;
+}
diff --git a/libs/installer_fomod_plus/installer/integration/FomodDataContent.h b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h
new file mode 100644
index 0000000..4ffa3d7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/integration/FomodDataContent.h
@@ -0,0 +1,21 @@
+#pragma once
+
+#include <imoinfo.h>
+#include <moddatacontent.h>
+
+namespace FomodDataContentConstants {
+constexpr int FOMOD_CONTENT_ID = 400400;
+}
+
+class FomodDataContent final : public MOBase::ModDataContent {
+public:
+ explicit FomodDataContent(MOBase::IOrganizer* organizer);
+
+ [[nodiscard]] std::vector<Content> getAllContents() const override;
+
+ [[nodiscard]] std::vector<int> getContentsFor(std::shared_ptr<const MOBase::IFileTree> fileTree) const override;
+
+private:
+ MOBase::IOrganizer* mOrganizer;
+ static bool modHasFomodContent(const MOBase::IModInterface* mod);
+};
diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp
new file mode 100644
index 0000000..c35bf47
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.cpp
@@ -0,0 +1,176 @@
+#include "ConditionTester.h"
+
+#include "ui/FomodViewModel.h"
+
+#include <iplugingame.h>
+#include <ipluginlist.h>
+
+std::string setToString(const std::set<int> &set)
+{
+ std::string str;
+ for (const auto& i : set) {
+ str += std::to_string(i) + ", ";
+ }
+ return str;
+}
+
+bool ConditionTester::isStepVisible(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency,
+ const int stepIndex,
+ const std::vector<std::shared_ptr<StepViewModel>>& steps) const
+{
+
+ // first things first: is it visible?
+ if (!testCompositeDependency(flags, compositeDependency)) {
+ return false;
+ }
+
+ const auto flagDependencies = compositeDependency.flagDependencies;
+ if (flagDependencies.empty()) {
+ return true;
+ }
+
+ std::set<int> stepsThatSetThisFlag;
+
+ for (const auto& flagDependency : flagDependencies) {
+ // for this flag, find the plugins that set it
+ for (int i = stepIndex - 1; i >= 0; --i) {
+ for (const auto& group : steps[i]->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ if (std::ranges::any_of(plugin->getPlugin()->conditionFlags.flags,
+ [&flagDependency](const ConditionFlag& flag) {
+ return flag.name == flagDependency.flag && flag.value == flagDependency.value;
+ })) {
+ stepsThatSetThisFlag.insert(i);
+ }
+ }
+ }
+ }
+ }
+ const auto anyVisible = std::ranges::any_of(stepsThatSetThisFlag, [this, &steps, &flags](const int index) {
+ return isStepVisible(flags, steps[index]->getVisibilityConditions(), index, steps);
+ });
+ if (!anyVisible) {
+ log.logMessage(DEBUG, "Step " + steps[stepIndex]->getName() + " has no dependent steps that are visible.");
+ log.logMessage(DEBUG, "Steps that set this flag: " + setToString(stepsThatSetThisFlag));
+ }
+ return anyVisible;
+
+}
+
+bool ConditionTester::testCompositeDependency(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency) const
+{
+ const auto fileDependencies = compositeDependency.fileDependencies;
+ const auto flagDependencies = compositeDependency.flagDependencies;
+ const auto gameDependencies = compositeDependency.gameDependencies;
+ const auto nestedDependencies = compositeDependency.nestedDependencies;
+ const auto globalOperatorType = compositeDependency.operatorType;
+
+ // For the globalOperatorType
+ // Evaluate all conditions and store the results in a vector<bool>, then return based on operator.
+ // These aren't expensive to calculate so rather than do some fancy logic to short-circuit, just calculate all of 'em.
+ std::vector<bool> results;
+ for (const auto& fileDependency : fileDependencies) {
+ results.emplace_back(testFileDependency(fileDependency));
+ }
+ for (const auto& flagDependency : flagDependencies) {
+ results.emplace_back(testFlagDependency(flags, flagDependency));
+ }
+ for (const auto& gameDependency : gameDependencies) {
+ results.emplace_back(testGameDependency(gameDependency));
+ }
+ for (const auto& nestedDependency : nestedDependencies) {
+ results.emplace_back(testCompositeDependency(flags, nestedDependency));
+ }
+
+ if (globalOperatorType == OperatorTypeEnum::AND) {
+ return std::ranges::all_of(results, [](const bool result) { return result; });
+ }
+ return std::ranges::any_of(results, [](const bool result) { return result; });
+}
+
+
+bool ConditionTester::testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency)
+{
+ // Every instance of this flag being set in the map.
+ const auto flagList = flags->getFlagsByKey(flagDependency.flag);
+
+ // Find the first instance of this flag being set (in the order specified by getFlagsByKey)
+ if (flagList.empty()) {
+ // If the dependency value is an empty string, it means this flag should be unset.
+ // So if we don't have any value for this flag, the result is true.
+ return flagDependency.value.empty();
+ }
+
+ return flagList.front().second == flagDependency.value;
+}
+
+bool ConditionTester::testFileDependency(const FileDependency& fileDependency) const
+{
+ const std::string& pluginName = fileDependency.file;
+ const auto pluginState = getFileDependencyStateForPlugin(pluginName);
+ return pluginState == fileDependency.state;
+}
+
+bool ConditionTester::testGameDependency(const GameDependency& gameDependency) const
+{
+ const auto gameVersion = mOrganizer->managedGame()->gameVersion().toStdString();
+ log.logMessage(DEBUG, "Comparing condition version " + gameDependency.version + " against " + gameVersion);
+ if ( gameDependency.version <= gameVersion) {
+ log.logMessage(DEBUG, "Version matches!");
+ }
+ return gameDependency.version <= gameVersion;
+}
+
+FileDependencyTypeEnum ConditionTester::getFileDependencyStateForPlugin(const std::string& pluginName) const
+{
+ if (const auto it = pluginStateCache.find(pluginName); it != pluginStateCache.end()) {
+ return it->second;
+ }
+
+ const QFlags<MOBase::IPluginList::PluginState> pluginState = mOrganizer->pluginList()->state(
+ QString::fromStdString(pluginName));
+
+ FileDependencyTypeEnum state;
+
+ if (pluginState == MOBase::IPluginList::STATE_MISSING) {
+ state = FileDependencyTypeEnum::Missing;
+ } else if (pluginState == MOBase::IPluginList::STATE_INACTIVE) {
+ state = FileDependencyTypeEnum::Inactive;
+ } else if (pluginState == MOBase::IPluginList::STATE_ACTIVE) {
+ state = FileDependencyTypeEnum::Active;
+ } else {
+ state = FileDependencyTypeEnum::UNKNOWN_STATE;
+ }
+
+ pluginStateCache[pluginName] = state;
+ return state;
+}
+
+PluginTypeEnum ConditionTester::getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin,
+ const std::shared_ptr<FlagMap>& flags) const
+{
+ // NOTE: A plugin's ConditionFlags aren't the same thing as a step visibility one.
+ // A plugin's ConditionFlags are toggled based on the selection state of the plugin
+ // We only evaluate the typeDescriptor here.
+
+ // We will return the 'winning' type or the default. If multiple conditions are met,
+ // ...well, I'm not sure.
+ // ReSharper disable once CppTooWideScopeInitStatement
+ const auto& dependencyType = plugin->typeDescriptor.dependencyType;
+ for (const auto& pattern : dependencyType.patterns.patterns) {
+ if (testCompositeDependency(flags, pattern.dependencies)) {
+ return pattern.type;
+ }
+ }
+
+ // Sometimes authors do this.
+ if (plugin->typeDescriptor.type != PluginTypeEnum::Optional) {
+ return plugin->typeDescriptor.type;
+ }
+ if (plugin->typeDescriptor.dependencyType.defaultType.has_value()) {
+ return plugin->typeDescriptor.dependencyType.defaultType.value();
+ }
+ return PluginTypeEnum::Optional;
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/lib/ConditionTester.h b/libs/installer_fomod_plus/installer/lib/ConditionTester.h
new file mode 100644
index 0000000..09aa26e
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ConditionTester.h
@@ -0,0 +1,42 @@
+#pragma once
+
+#include <imoinfo.h>
+
+#include "FlagMap.h"
+#include "Logger.h"
+#include "xml/ModuleConfiguration.h"
+
+class CompositeDependency;
+class StepViewModel;
+
+class ConditionTester {
+public:
+ explicit ConditionTester(MOBase::IOrganizer* organizer) : mOrganizer(organizer) {}
+
+ bool isStepVisible(const std::shared_ptr<FlagMap>& flags, const CompositeDependency& compositeDependency,
+ int stepIndex,
+ const std::vector<std::shared_ptr<StepViewModel>>& steps) const;
+
+ bool testCompositeDependency(const std::shared_ptr<FlagMap>& flags,
+ const CompositeDependency& compositeDependency) const;
+
+ static bool testFlagDependency(const std::shared_ptr<FlagMap>& flags, const FlagDependency& flagDependency);
+
+ [[nodiscard]] bool testFileDependency(const FileDependency& fileDependency) const;
+
+ bool testGameDependency(const GameDependency& gameDependency) const;
+
+private:
+ Logger& log = Logger::getInstance();
+ MOBase::IOrganizer* mOrganizer;
+
+ friend class FomodViewModel;
+
+ [[nodiscard]] FileDependencyTypeEnum getFileDependencyStateForPlugin(const std::string& pluginName) const;
+
+ PluginTypeEnum getPluginTypeDescriptorState(const std::shared_ptr<Plugin>& plugin,
+ const std::shared_ptr<FlagMap>& flags) const;
+
+ mutable std::unordered_map<std::string, FileDependencyTypeEnum> pluginStateCache;
+
+};
diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp
new file mode 100644
index 0000000..98ed1f3
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.cpp
@@ -0,0 +1,122 @@
+#include "CrashHandler.h"
+#include <iostream>
+
+#ifdef _WIN32
+#include <windows.h>
+#include <dbghelp.h>
+#include <sstream>
+#include <fstream>
+
+LPTOP_LEVEL_EXCEPTION_FILTER CrashHandler::previousFilter = nullptr;
+PVOID CrashHandler::vectoredHandler = nullptr;
+#endif
+
+void CrashHandler::initialize() {
+#ifdef _WIN32
+ std::cout << "CrashHandler initializing..." << std::endl;
+ vectoredHandler = AddVectoredExceptionHandler(1, vectoredExceptionHandler);
+ previousFilter = SetUnhandledExceptionFilter(unhandledExceptionFilter);
+ std::cout << "CrashHandler initialized successfully" << std::endl;
+#else
+ // No-op on Linux — Windows crash handler not needed.
+#endif
+}
+
+void CrashHandler::cleanup() {
+#ifdef _WIN32
+ if (vectoredHandler) {
+ RemoveVectoredExceptionHandler(vectoredHandler);
+ vectoredHandler = nullptr;
+ }
+ if (previousFilter) {
+ SetUnhandledExceptionFilter(previousFilter);
+ previousFilter = nullptr;
+ }
+#endif
+}
+
+#ifdef _WIN32
+LONG WINAPI CrashHandler::vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo) {
+ // Log all exceptions but only create dumps for serious ones
+ std::cout << "Exception caught: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl;
+
+ // Only create dumps for serious crashes, not benign exceptions
+ if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION ||
+ exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW ||
+ exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ILLEGAL_INSTRUCTION) {
+
+ // For stack overflow, we need to handle it on a different thread with its own stack
+ if (exceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_STACK_OVERFLOW) {
+ // Create a new thread to handle the crash dump since our stack is corrupted
+ HANDLE dumpThread = CreateThread(nullptr, 0, [](LPVOID param) -> DWORD {
+ auto* exceptionInfo = static_cast<EXCEPTION_POINTERS*>(param);
+ logCrashInfo(exceptionInfo);
+
+ HANDLE file = CreateFileA("fomod_plus_stack_overflow.dmp", GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
+
+ if (file != INVALID_HANDLE_VALUE) {
+ MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0};
+ dumpInfo.ThreadId = GetCurrentThreadId();
+ dumpInfo.ExceptionPointers = exceptionInfo;
+ dumpInfo.ClientPointers = FALSE;
+
+ MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file,
+ MiniDumpNormal, &dumpInfo, nullptr, nullptr);
+ CloseHandle(file);
+
+ std::cout << "Stack overflow crash dump written to fomod_plus_stack_overflow.dmp" << std::endl;
+ }
+ return 0;
+ }, exceptionInfo, 0, nullptr);
+
+ if (dumpThread) {
+ WaitForSingleObject(dumpThread, 5000); // Wait up to 5 seconds
+ CloseHandle(dumpThread);
+ }
+ } else {
+ logCrashInfo(exceptionInfo);
+
+ // Generate minidump
+ HANDLE file = CreateFileA("fomod_plus_crash.dmp", GENERIC_WRITE, 0, nullptr,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
+
+ if (file != INVALID_HANDLE_VALUE) {
+ MINIDUMP_EXCEPTION_INFORMATION dumpInfo = {0};
+ dumpInfo.ThreadId = GetCurrentThreadId();
+ dumpInfo.ExceptionPointers = exceptionInfo;
+ dumpInfo.ClientPointers = FALSE;
+
+ MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), file,
+ MiniDumpNormal, &dumpInfo, nullptr, nullptr);
+ CloseHandle(file);
+
+ std::cout << "Crash dump written to fomod_plus_crash.dmp" << std::endl;
+ }
+ }
+ }
+
+ return EXCEPTION_CONTINUE_SEARCH; // Let other handlers process it
+}
+
+LONG WINAPI CrashHandler::unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo) {
+ logCrashInfo(exceptionInfo);
+ return EXCEPTION_EXECUTE_HANDLER;
+}
+
+void CrashHandler::logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo) {
+ std::ostringstream oss;
+ oss << "FOMOD PLUS CRASH DETECTED!" << std::endl;
+ oss << "Exception Code: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionCode << std::endl;
+ oss << "Exception Address: 0x" << std::hex << exceptionInfo->ExceptionRecord->ExceptionAddress << std::endl;
+
+ // Write to stdout immediately
+ std::cout << oss.str() << std::endl;
+
+ // Also write to crash log file
+ std::ofstream crashLog("fomod_plus_crash.log", std::ios::app);
+ if (crashLog.is_open()) {
+ crashLog << oss.str() << std::endl;
+ }
+}
+#endif
diff --git a/libs/installer_fomod_plus/installer/lib/CrashHandler.h b/libs/installer_fomod_plus/installer/lib/CrashHandler.h
new file mode 100644
index 0000000..78c8552
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/CrashHandler.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#ifdef _WIN32
+#include <windows.h>
+#endif
+
+class CrashHandler {
+public:
+ static void initialize();
+ static void cleanup();
+
+private:
+#ifdef _WIN32
+ static LONG WINAPI unhandledExceptionFilter(EXCEPTION_POINTERS* exceptionInfo);
+ static LONG WINAPI vectoredExceptionHandler(EXCEPTION_POINTERS* exceptionInfo);
+ static void logCrashInfo(const EXCEPTION_POINTERS* exceptionInfo);
+ static LPTOP_LEVEL_EXCEPTION_FILTER previousFilter;
+ static PVOID vectoredHandler;
+#endif
+};
diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp
new file mode 100644
index 0000000..e47e088
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.cpp
@@ -0,0 +1,274 @@
+#include "FileInstaller.h"
+
+#include <utility>
+
+#include "ui/FomodViewModel.h"
+
+using namespace MOBase;
+
+FileInstaller::FileInstaller(
+ IOrganizer* organizer,
+ QString fomodPath,
+ const std::shared_ptr<IFileTree>& fileTree,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ const std::shared_ptr<FlagMap>& flagMap,
+ const std::vector<std::shared_ptr<StepViewModel> >& steps) : mOrganizer(organizer),
+ mFomodPath(std::move(fomodPath)),
+ mFileTree(fileTree),
+ mFomodFile(std::move(fomodFile)),
+ mFlagMap(flagMap),
+ mConditionTester(organizer), mSteps(steps)
+{
+ const auto requiredCount =
+ (mFomodFile != nullptr) ? mFomodFile->requiredInstallFiles.files.size() : 0;
+ const auto stepCount = mSteps.size();
+ logMessage(DEBUG,
+ "FileInstaller constructed. steps=" + std::to_string(stepCount) + ", required files=" +
+ std::to_string(requiredCount) + ", fomodPath=" + mFomodPath.toStdString());
+}
+
+std::shared_ptr<IFileTree> FileInstaller::install() const
+{
+ logMessage(DEBUG, "Starting FileInstaller::install()");
+ const auto filesToInstall = collectFilesToInstall();
+ logMessage(INFO, "Installing " + std::to_string(filesToInstall.size()) + " files");
+ logMessage(INFO, "FlagMap");
+ logMessage(INFO, mFlagMap->toString());
+
+ // update the file tree with the new files
+ const std::shared_ptr<IFileTree> installTree = mFileTree->createOrphanTree();
+
+ for (const auto& file : filesToInstall) {
+
+ logMessage(DEBUG,
+ "Processing install entry source=" + file.source + ", destination=" +
+ (file.destination.has_value() ? file.destination.value() : "<default>") +
+ ", priority=" + std::to_string(file.priority));
+ const auto sourcePath = getQualifiedFilePath(file.source);
+ const auto sourceNode = mFileTree->find(QString::fromStdString(sourcePath));
+ if (sourceNode == nullptr) {
+ logMessage(ERR, "Could not find source: " + file.source);
+ continue;
+ }
+ const auto targetPath = file.destination.has_value()
+ ? QString::fromStdString(file.destination.value())
+ : QString::fromStdString(sourcePath);
+
+ // If it's a folder, copy the contents of the folder, not the folder itself.
+ if (sourceNode->isDir()) {
+ logMessage(DEBUG,
+ "Copying directory '" + file.source + "' into target '" + targetPath.toStdString() +
+ "'");
+ // TODO: Check if target path is literally undefined/null
+ const auto& tree = sourceNode->astree();
+ for (auto it = tree->begin(); it != tree->end(); ++it) {
+ const auto& entry = *it;
+ const auto path = targetPath.isEmpty() ? entry->name() : targetPath + "/" + entry->name();
+ logMessage(DEBUG,
+ " Copying entry '" + entry->name().toStdString() + "' to '" +
+ path.toStdString() + "'");
+ installTree->copy(entry, path, IFileTree::InsertPolicy::MERGE);
+ }
+ } else {
+ logMessage(DEBUG,
+ "Copying file '" + file.source + "' to '" + targetPath.toStdString() + "'");
+ installTree->copy(sourceNode, targetPath, IFileTree::InsertPolicy::MERGE);
+ }
+ }
+
+ // This file will be written by the InstallationManager later.
+ const auto jsonFilePath = "fomod.json";
+ installTree->addFile(QString::fromStdString(jsonFilePath), true);
+ logMessage(DEBUG, "Added fomod.json placeholder file to install tree.");
+
+ logMessage(DEBUG, "FileInstaller::install completed.");
+ return installTree;
+}
+
+nlohmann::json FileInstaller::generateFomodJson() const
+{
+ nlohmann::json fomodJson;
+ logMessage(DEBUG, "Generating fomod.json representation for " + std::to_string(mSteps.size()) +
+ " steps.");
+
+ fomodJson["steps"] = nlohmann::json::array();
+ for (const auto& stepViewModel : mSteps) {
+ auto stepJson = nlohmann::json::object();
+ stepJson["name"] = stepViewModel->getName();
+ stepJson["groups"] = nlohmann::json::array();
+ logMessage(DEBUG, "Serializing step '" + stepViewModel->getName() + "'");
+
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ auto groupJson = nlohmann::json::object();
+ groupJson["name"] = groupViewModel->getName();
+ auto pluginArray = nlohmann::json::array();
+ auto deselectedArray = nlohmann::json::array();
+ logMessage(DEBUG,
+ " Serializing group '" + groupViewModel->getName() +
+ "' with " + std::to_string(groupViewModel->getPlugins().size()) + " plugins");
+
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ logMessage(DEBUG,
+ " Plugin '" + pluginViewModel->getName() + "' selected=" +
+ (pluginViewModel->isSelected() ? "true" : "false") + ", manually-set=" +
+ (pluginViewModel->wasManuallySet() ? "true" : "false"));
+ if (pluginViewModel->isSelected()) {
+ pluginArray.emplace_back(pluginViewModel->getName());
+ }
+ // Add deselected plugins here. (TODO: This will be replaced with an embedded db.)
+ if (!pluginViewModel->isSelected() && pluginViewModel->wasManuallySet()) {
+ deselectedArray.emplace_back(pluginViewModel->getName());
+ }
+ }
+ groupJson["plugins"] = pluginArray;
+ groupJson["deselected"] = deselectedArray;
+ stepJson["groups"].emplace_back(groupJson);
+ }
+ fomodJson["steps"].emplace_back(stepJson);
+ }
+ return fomodJson;
+}
+
+std::string FileInstaller::getQualifiedFilePath(const std::string& treePath) const
+{
+ // We need to prepend the fomod path to whatever source we reference. Guess we're passing that path around.
+ const auto qualifiedPath = mFomodPath.toStdString() + "/" + treePath;
+ logMessage(DEBUG, "Qualifying tree path '" + treePath + "' to '" + qualifiedPath + "'");
+ return qualifiedPath;
+}
+
+std::vector<std::string> FileInstaller::collectPositiveFileNamesFromDependencyPatterns(
+ const std::vector<DependencyPattern>& patterns)
+{
+ std::vector<std::string> usableFileDependencyPluginNames = {};
+ logMessage(DEBUG,
+ "Collecting positive file names from " + std::to_string(patterns.size()) + " patterns.");
+
+ for (const auto& pattern : patterns) {
+ if (pattern.type == PluginTypeEnum::NotUsable) {
+ logMessage(DEBUG, "Skipping pattern marked as NotUsable.");
+ continue;
+ }
+
+ if (pattern.dependencies.fileDependencies.empty() && pattern.dependencies.nestedDependencies.empty()) {
+ logMessage(DEBUG, "Skipping pattern with no dependencies.");
+ continue;
+ }
+
+ const auto fileDependencies = pattern.dependencies.fileDependencies;
+ const auto nestedDependencies = pattern.dependencies.nestedDependencies;
+
+ for (const auto& fileDependency : fileDependencies) {
+ if (fileDependency.state != FileDependencyTypeEnum::Active) {
+ continue;
+ }
+ usableFileDependencyPluginNames.emplace_back(fileDependency.file);
+ logMessage(DEBUG, "Adding active file dependency: " + fileDependency.file);
+ }
+
+ for (const auto& nestedDependency : nestedDependencies) {
+ for (const auto& fileDependency : nestedDependency.fileDependencies) {
+ if (fileDependency.state != FileDependencyTypeEnum::Active) {
+ continue;
+ }
+ usableFileDependencyPluginNames.emplace_back(fileDependency.file);
+ logMessage(DEBUG, "Adding nested active file dependency: " + fileDependency.file);
+ }
+ }
+ // Not handling twice-nested dependencies now. IDK if that's even feasible.
+ }
+
+ logMessage(DEBUG,
+ "CollectPositiveFileNamesFromDependencyPatterns found " +
+ std::to_string(usableFileDependencyPluginNames.size()) + " entries.");
+ return usableFileDependencyPluginNames;
+}
+
+// Generic vector appender
+void FileInstaller::addFiles(std::vector<File>& main, std::vector<File> toAdd) const
+{
+ for (const auto& add : toAdd) {
+ logMessage(INFO, "Adding file with source: " + add.source);
+ }
+ main.insert(main.end(), toAdd.begin(), toAdd.end());
+ logMessage(DEBUG,
+ "addFiles merged " + std::to_string(toAdd.size()) + " files. Total is now " +
+ std::to_string(main.size()));
+}
+
+// TODO: Unclear if we're copying. oh well.
+// TODO: Rebuild flagmap and step indeces before installing
+std::vector<File> FileInstaller::collectFilesToInstall() const
+{
+ logMessage(DEBUG, "collectFilesToInstall started.");
+ std::vector<File> allFiles;
+
+ // Required files from FOMOD
+ const FileList requiredInstallFiles = mFomodFile->requiredInstallFiles;
+ logMessage(DEBUG,
+ "Adding " + std::to_string(requiredInstallFiles.files.size()) +
+ " required install files from fomod.");
+ addFiles(allFiles, requiredInstallFiles.files);
+
+ // Selected files from visible steps
+ for (const auto& stepViewModel : mSteps) {
+ if (!mConditionTester.testCompositeDependency(mFlagMap, stepViewModel->getVisibilityConditions())) {
+ logMessage(DEBUG,
+ "Skipping invisible step '" + stepViewModel->getName() + "'");
+ continue;
+ }
+ logMessage(DEBUG,
+ "Processing visible step '" + stepViewModel->getName() + "' with " +
+ std::to_string(stepViewModel->getGroups().size()) + " groups.");
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ logMessage(DEBUG,
+ " Processing group '" + groupViewModel->getName() + "' with " +
+ std::to_string(groupViewModel->getPlugins().size()) + " plugins.");
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ if (pluginViewModel->isSelected()) {
+ logMessage(DEBUG,
+ " Adding selected plugin '" + pluginViewModel->getName() +
+ "' with " +
+ std::to_string(pluginViewModel->getPlugin()->files.files.size()) +
+ " files.");
+ addFiles(allFiles, pluginViewModel->getPlugin()->files.files);
+ } else {
+ logMessage(DEBUG,
+ " Skipping plugin '" + pluginViewModel->getName() +
+ "' (not selected).");
+ }
+ }
+ }
+ }
+
+ // ConditionalInstall files
+ for (const auto conditionals = mFomodFile->conditionalFileInstalls; const auto& pattern : conditionals.patterns) {
+ //<folder source="CR\Dagi-Raht LL\VLrn_Custom Race - Dagi-Raht LL" destination="" priority="2" />
+
+ if (mConditionTester.testCompositeDependency(mFlagMap, pattern.dependencies)) {
+ // also check if the plugins setting these flags are visible. at least one
+
+ addFiles(allFiles, pattern.files.files);
+ logMessage(DEBUG,
+ "Conditional install pattern matched; added " +
+ std::to_string(pattern.files.files.size()) + " files.");
+ } else {
+ logMessage(DEBUG, "Conditional install pattern did not match.");
+ }
+ }
+
+ // Files will all have a default priority of 0 if not specified, so the order should also be informed by the
+ // order they appear within XML. That's why we put conditionalFileInstalls after.
+ logMessage(DEBUG, "Sorting " + std::to_string(allFiles.size()) + " files by priority.");
+ std::ranges::sort(allFiles, [](const auto& a, const auto& b) {
+ return a.priority < b.priority;
+ });
+
+ for (const auto& toInstall : allFiles) {
+ logMessage(DEBUG, "File to install: " + toInstall.source);
+ }
+ logMessage(DEBUG,
+ "collectFilesToInstall completed with " + std::to_string(allFiles.size()) + " files.");
+
+ return allFiles;
+}
diff --git a/libs/installer_fomod_plus/installer/lib/FileInstaller.h b/libs/installer_fomod_plus/installer/lib/FileInstaller.h
new file mode 100644
index 0000000..1c122a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FileInstaller.h
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <ifiletree.h>
+#include <nlohmann/json.hpp>
+
+#include "ConditionTester.h"
+#include "FlagMap.h"
+#include "Logger.h"
+#include "xml/ModuleConfiguration.h"
+
+
+/* This is what legacy fomodInstaller does:
+ modName.update(dialog.getName(), GUESS_USER);
+ return dialog.updateTree(tree);
+
+ This is a link to the legacy updateTree method:
+ https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/fc263f2d923c704b4853c11ed4f8b8cf3920f30d/src/fomodinstallerdialog.cpp#L546
+
+*/
+
+// Things this should do:
+// - Copy all requiredInstall files
+// - Copy all conditionalInstall files
+// - Copy all selected files from steps that were visible to the user at the point of install
+
+using namespace MOBase;
+
+using FileGlobalIndex = int;
+using FileDescriptor = std::pair<File, FileGlobalIndex>;
+
+class StepViewModel;
+
+class FileInstaller {
+public:
+ FileInstaller(
+ IOrganizer* organizer,
+ QString fomodPath,
+ const std::shared_ptr<IFileTree>& fileTree,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ const std::shared_ptr<FlagMap>& flagMap,
+ const std::vector<std::shared_ptr<StepViewModel> >& steps);
+
+ std::shared_ptr<IFileTree> install() const;
+
+ /**
+ * @brief Create a 'fomod.json' file to add to the base of the installTree. Functionally similar to MO2's meta.ini.
+ *
+ * Until there's more utility in the JSON structure itself, it will simply be of this format:
+ * @code
+ * {
+ * "steps": [
+ * {
+ * "name": "Step 1",
+ * "groups": [
+ * {
+ * "name": "Group 1",
+ * "plugins: [
+ * "Plugin1.esp",
+ * "Plugin2.esp"
+ * ]
+ * }
+ * ]
+ * }
+ * ]
+ * }
+ * @endcode
+ *
+ * @return nhlohmann::json
+ */
+ nlohmann::json generateFomodJson() const;
+
+ std::string getQualifiedFilePath(const std::string& treePath) const;
+
+ std::vector<std::string> collectPositiveFileNamesFromDependencyPatterns(const std::vector<DependencyPattern> &patterns);
+
+ void addFiles(std::vector<File>& main, std::vector<File> toAdd) const;
+
+private:
+ IOrganizer* mOrganizer;
+ Logger& log = Logger::getInstance();
+ QString mFomodPath;
+ std::shared_ptr<IFileTree> mFileTree;
+ std::unique_ptr<ModuleConfiguration> mFomodFile;
+ std::shared_ptr<FlagMap> mFlagMap;
+ ConditionTester mConditionTester;
+ std::vector<std::shared_ptr<StepViewModel> > mSteps; // TODO: Maybe this is nasty. Idk.
+
+ std::vector<File> collectFilesToInstall() const;
+
+ void logMessage(LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[INSTALLER] " + message);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/lib/FlagMap.h b/libs/installer_fomod_plus/installer/lib/FlagMap.h
new file mode 100644
index 0000000..4959cec
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/FlagMap.h
@@ -0,0 +1,113 @@
+#pragma once
+
+#include "ViewModels.h"
+#include "stringutil.h"
+
+#include <ranges>
+#include <string>
+#include <unordered_map>
+
+using Flag = std::pair<std::string, std::string>;
+using FlagList = std::vector<Flag>;
+
+class FlagMap {
+public:
+
+ [[nodiscard]] std::vector<std::shared_ptr<PluginViewModel>> getPluginsSettingFlag(const std::string& key, const std::string& value) const
+ {
+ std::vector<std::shared_ptr<PluginViewModel>> result;
+ for (const auto& [plugin, theseFlags] : flags) {
+ for (const auto& [fst, snd] : theseFlags) {
+ if (toLower(fst) == toLower(key) && snd == value) {
+ result.emplace_back(plugin);
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ *
+ * @param key The flag key
+ * @return A list of flags currently set in this map with the given key. The list is ordered by step descending, then plugin ascending.
+ * So if steps 1, 2, and 3 set flag X in their first two plugins, it'll be ordered [3:1, 3:2, 2:1, 2:2, 1:2, 1:1]
+ */
+ [[nodiscard]] FlagList getFlagsByKey(const std::string& key) const
+ {
+ FlagList result;
+ std::vector<std::pair<int, std::shared_ptr<PluginViewModel>>> orderedPlugins;
+
+ // Collect all plugins with their stepIndex and ownIndex
+ for (const auto& plugin : flags | std::views::keys) {
+ orderedPlugins.emplace_back(plugin->getStepIndex(), plugin);
+ }
+
+ // Sort plugins by stepIndex and ownIndex, stepIndex descending and ownIndex ascending
+ // TODO: Might need group sorting too. How can I just do the natural order??
+ std::ranges::sort(orderedPlugins, [](const auto& a, const auto& b) {
+ return a.first > b.first || (a.first == b.first && a.second->getOwnIndex() < b.second->getOwnIndex());
+ });
+
+ // Collect flags in the sorted order
+ for (const auto& plugin : orderedPlugins | std::views::values) {
+ for (const auto& flag : flags.at(plugin)) {
+ if (toLower(flag.first) == toLower(key)) {
+ result.emplace_back(flag);
+ }
+ }
+ }
+ return result;
+ }
+
+ void setFlagsForPlugin(PluginRef plugin)
+ {
+ // Don't clutter the map with empty key-vals
+ if (plugin->getConditionFlags().empty()) {
+ return;
+ }
+ unsetFlagsForPlugin(plugin);
+
+ FlagList flagList;
+ for (const auto& conditionFlag : plugin->getConditionFlags()) {
+ flagList.emplace_back(toLower(conditionFlag.name), conditionFlag.value);
+ }
+ flags[plugin] = flagList;
+ }
+
+ void unsetFlagsForPlugin(PluginRef plugin)
+ {
+ if (flags.contains(plugin)) {
+ flags.erase(plugin);
+ }
+ }
+
+ std::string toString()
+ {
+ auto result = std::string();
+ result += "FlagMap:\n";
+
+ for (const auto& [plugin, theseFlags] : flags) {
+ result += plugin->getName() + " [";
+ for (const auto& [fst, snd] : theseFlags) {
+ result += fst + ": " + snd + ", ";
+ }
+ result.erase(result.size() - 2);
+ result += "]\n";
+ }
+ return result;
+ }
+
+ void clearAll()
+ {
+ flags.clear();
+ }
+
+ [[nodiscard]] size_t getFlagCount() const
+ {
+ return flags.size();
+ }
+
+
+private:
+ std::unordered_map<std::shared_ptr<PluginViewModel>, FlagList> flags;
+};
diff --git a/libs/installer_fomod_plus/installer/lib/Logger.h b/libs/installer_fomod_plus/installer/lib/Logger.h
new file mode 100644
index 0000000..fdeb1f0
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/Logger.h
@@ -0,0 +1,118 @@
+#pragma once
+#include <fstream>
+#include <iostream>
+#include <mutex>
+#include <regex>
+
+
+// Log levels
+enum LogLevel {
+ DEBUG = 0,
+ INFO = 1,
+ WARN = 2,
+ ERR = 3
+};
+
+// Convert LogLevel to string
+inline const char* logLevelToString(const LogLevel level) {
+ switch (level) {
+ case DEBUG: return "DEBUG";
+ case INFO: return "INFO";
+ case WARN: return "WARN";
+ case ERR: return "ERROR";
+ default: return "UNKNOWN";
+ }
+}
+
+
+class Logger {
+public:
+ static Logger& getInstance()
+ {
+ static Logger instance;
+ return instance;
+ }
+
+ void setLogFilePath(const std::string& filePath)
+ {
+ std::lock_guard lock(mMutex);
+ if (mLogFile.is_open()) {
+ mLogFile.close();
+ }
+ mLogFile.open(filePath, std::ios::out);
+ // std::ios::app is an option for appending but dont wanna grow it forever.
+ }
+
+ void setDebugMode(bool debug)
+ {
+ std::lock_guard lock(mMutex);
+ mDebugMode = debug;
+ }
+
+ void logMessage(const LogLevel level, const std::string& message)
+ {
+
+#if defined(__GNUC__) || defined(__clang__)
+ std::string functionName = __PRETTY_FUNCTION__;
+#elif defined(_MSC_VER)
+ std::string functionName = __FUNCSIG__;
+#else
+ std::string functionName = "UnknownFunction";
+#endif
+
+ std::regex classNameRegex(R"((\w+)::\w+\()");
+ std::smatch match;
+ std::string className = "UnknownClass";
+
+ if (std::regex_search(functionName, match, classNameRegex) && match.size() > 1) {
+ className = match.str(1);
+ }
+
+ std::lock_guard lock(mMutex);
+
+ auto writeLog = [&](std::ostream& stream) {
+ switch (level) {
+ case DEBUG:
+ stream << "[DEBUG] " << message << std::endl;
+ break;
+ case INFO:
+ stream << "[INFO] " << message << std::endl;
+ break;
+ case WARN:
+ stream << "[WARN] " << message << std::endl;
+ break;
+ case ERR:
+ stream << "[ERROR] " << message << std::endl;
+ break;
+ }
+ };
+
+ if (mLogFile.is_open()) {
+ writeLog(mLogFile);
+ }
+
+ writeLog(std::cout);
+ }
+
+ Logger& operator=(const Logger&) = delete;
+
+private:
+ Logger() = default;
+
+ ~Logger()
+ {
+ if (mLogFile.is_open()) {
+ mLogFile.close();
+ }
+ }
+
+ Logger(const Logger&) = delete;
+
+ std::ofstream mLogFile;
+ std::mutex mMutex;
+#if !defined(NDEBUG) || defined(CMAKE_BUILD_TYPE_RELWITHDEBINFO)
+ bool mDebugMode = true; // Auto-enable in debug/RelWithDebInfo builds
+#else
+ bool mDebugMode = false; // Disable in release builds
+#endif
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/lib/ViewModels.h b/libs/installer_fomod_plus/installer/lib/ViewModels.h
new file mode 100644
index 0000000..062445a
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/lib/ViewModels.h
@@ -0,0 +1,120 @@
+#pragma once
+
+#include <memory>
+#include <vector>
+
+#include "xml/ModuleConfiguration.h"
+
+
+template <typename T>
+using shared_ptr_list = std::vector<std::shared_ptr<T> >;
+
+
+/*
+--------------------------------------------------------------------------------
+ Plugins
+--------------------------------------------------------------------------------
+*/
+class PluginViewModel {
+public:
+ PluginViewModel(const std::shared_ptr<Plugin>& plugin_, const bool selected, bool, const int index)
+ : ownIndex(index), selected(selected), enabled(true), manuallySet(false), plugin(plugin_) {}
+
+ void setSelected(const bool selected) { this->selected = selected; }
+ void setEnabled(const bool enabled) { this->enabled = enabled; }
+ [[nodiscard]] std::string getName() const { return plugin ? plugin->name : std::string(); }
+ [[nodiscard]] std::string getDescription() const { return plugin ? plugin->description : std::string(); }
+ [[nodiscard]] std::string getImagePath() const { return plugin ? plugin->image.path : std::string(); }
+ [[nodiscard]] bool isSelected() const { return selected; }
+ [[nodiscard]] bool isEnabled() const { return enabled; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] std::vector<ConditionFlag> getConditionFlags() const { return plugin->conditionFlags.flags; }
+ [[nodiscard]] PluginTypeEnum getCurrentPluginType() const { return currentPluginType; }
+ [[nodiscard]] bool wasManuallySet() const { return manuallySet; }
+
+ void setCurrentPluginType(const PluginTypeEnum type) { currentPluginType = type; }
+ void setStepIndex(const int stepIndex) { this->stepIndex = stepIndex; }
+ void setGroupIndex(const int groupIndex) { this->groupIndex = groupIndex; }
+
+ [[nodiscard]] int getStepIndex() const { return stepIndex; }
+ [[nodiscard]] int getGroupIndex() const { return groupIndex; }
+
+ friend class FomodViewModel;
+ friend class FileInstaller;
+ friend class ConditionTester;
+
+protected:
+ [[nodiscard]] std::shared_ptr<Plugin> getPlugin() const { return plugin; }
+
+private:
+ int ownIndex;
+ bool selected;
+ bool enabled;
+ bool manuallySet;
+ PluginTypeEnum currentPluginType = PluginTypeEnum::UNKNOWN;
+ std::shared_ptr<Plugin> plugin;
+
+ int stepIndex{ -1 };
+ int groupIndex{ -1 };
+};
+
+/*
+--------------------------------------------------------------------------------
+ Groups
+--------------------------------------------------------------------------------
+*/
+class GroupViewModel {
+public:
+ GroupViewModel(const std::shared_ptr<Group>& group_, const shared_ptr_list<PluginViewModel>& plugins,
+ const int index, const int stepIndex)
+ : plugins(plugins), group(group_), ownIndex(index), stepIndex(stepIndex) {}
+
+ void addPlugin(const std::shared_ptr<PluginViewModel>& plugin) { plugins.emplace_back(plugin); }
+
+ [[nodiscard]] std::string getName() const { return group->name; }
+ [[nodiscard]] GroupTypeEnum getType() const { return group->type; }
+ [[nodiscard]] const shared_ptr_list<PluginViewModel>& getPlugins() const { return plugins; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] int getStepIndex() const { return stepIndex; }
+
+private:
+ shared_ptr_list<PluginViewModel> plugins;
+ std::shared_ptr<Group> group;
+ int ownIndex;
+ int stepIndex;
+};
+
+/*
+--------------------------------------------------------------------------------
+ Steps
+--------------------------------------------------------------------------------
+*/
+class StepViewModel {
+public:
+ StepViewModel(const std::shared_ptr<InstallStep>& installStep_, const shared_ptr_list<GroupViewModel>& groups,
+ const int index)
+ : installStep(installStep_), groups(groups), ownIndex(index) {}
+
+ [[nodiscard]] CompositeDependency& getVisibilityConditions() const { return installStep->visible; }
+ [[nodiscard]] std::string getName() const { return installStep->name; }
+ [[nodiscard]] const shared_ptr_list<GroupViewModel>& getGroups() const { return groups; }
+ [[nodiscard]] int getOwnIndex() const { return ownIndex; }
+ [[nodiscard]] bool getHasVisited() const { return visited; }
+ void setVisited(const bool visited) { this->visited = visited; }
+
+private:
+ bool visited{ false };
+ std::shared_ptr<InstallStep> installStep;
+ shared_ptr_list<GroupViewModel> groups;
+ int ownIndex;
+};
+
+
+/*
+--------------------------------------------------------------------------------
+ Outbound Types
+--------------------------------------------------------------------------------
+*/
+using StepRef = const std::shared_ptr<StepViewModel>&;
+using GroupRef = const std::shared_ptr<GroupViewModel>&;
+using PluginRef = const std::shared_ptr<PluginViewModel>&;
diff --git a/libs/installer_fomod_plus/installer/resources.qrc b/libs/installer_fomod_plus/installer/resources.qrc
new file mode 100644
index 0000000..6c583a7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources.qrc
@@ -0,0 +1,14 @@
+<RCC>
+ <qresource prefix="/fomod">
+ <file alias="hat">resources/fomod_icon.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="previous">resources/left-chevron.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="next">resources/right-chevron.png</file>
+ </qresource>
+ <qresource prefix="/fomod">
+ <file alias="close">resources/delete.png</file>
+ </qresource>
+</RCC> \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/resources/delete.png b/libs/installer_fomod_plus/installer/resources/delete.png
new file mode 100644
index 0000000..f6502b1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/delete.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/fomod_icon.png b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
new file mode 100644
index 0000000..e044052
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/fomod_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/image_icon.png b/libs/installer_fomod_plus/installer/resources/image_icon.png
new file mode 100644
index 0000000..b300751
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/image_icon.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/left-chevron.png b/libs/installer_fomod_plus/installer/resources/left-chevron.png
new file mode 100644
index 0000000..28222a1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/left-chevron.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/resources/right-chevron.png b/libs/installer_fomod_plus/installer/resources/right-chevron.png
new file mode 100644
index 0000000..1a0af49
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/resources/right-chevron.png
Binary files differ
diff --git a/libs/installer_fomod_plus/installer/ui/ClickableWidget.h b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h
new file mode 100644
index 0000000..881600f
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ClickableWidget.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <QMouseEvent>
+#include <QWidget>
+
+class ClickableWidget final : public QWidget {
+ Q_OBJECT
+
+public:
+ explicit ClickableWidget(QWidget* parent = nullptr) : QWidget(parent) {
+ setCursor(Qt::PointingHandCursor);
+ }
+
+ signals:
+ void clicked();
+
+protected:
+ void mousePressEvent(QMouseEvent* event) override {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QWidget::mousePressEvent(event);
+ }
+};
diff --git a/libs/installer_fomod_plus/installer/ui/Colors.h b/libs/installer_fomod_plus/installer/ui/Colors.h
new file mode 100644
index 0000000..c3262eb
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/Colors.h
@@ -0,0 +1,155 @@
+#pragma once
+
+#include <QString>
+#include <map>
+
+namespace UiColors {
+
+enum class ColorApplication {
+ BACKGROUND,
+ BORDER,
+ TEXT,
+ ALL
+};
+
+// Color values
+namespace Colors {
+ // Light
+ const QString Light0 = "251, 241, 199";
+ const QString Light1 = "235, 219, 178";
+ const QString Light2 = "213, 196, 161";
+ const QString Light3 = "189, 174, 147";
+
+ // Dark
+ const QString Dark0 = "40, 40, 40";
+ const QString Dark1 = "60, 56, 54";
+ const QString Dark2 = "80, 73, 69";
+ const QString Dark3 = "102, 92, 84";
+
+ // Red
+ const QString Red = "204, 36, 29";
+ const QString RedBright = "251, 73, 52";
+
+ // Green
+ const QString Green = "152, 151, 26";
+ const QString GreenBright = "184, 187, 38";
+
+ // Yellow
+ const QString Yellow = "215, 153, 33";
+ const QString YellowBright = "250, 189, 47";
+
+ // Blue
+ const QString Blue = "69, 133, 136";
+ const QString BlueBright = "131, 165, 152";
+
+ // Purple
+ const QString Purple = "177, 98, 134";
+ const QString PurpleBright = "211, 134, 155";
+
+ // Aqua
+ const QString Aqua = "104, 157, 106";
+ const QString AquaBright = "142, 192, 124";
+
+ // Orange
+ const QString Orange = "214, 93, 14";
+ const QString OrangeBright = "254, 128, 25";
+}
+
+// Helper function to generate style strings based on color and application
+inline QString generateStyle(const QString& color, const ColorApplication application, const float opacity = 0.4,
+ const int borderWidth = 1)
+{
+ QString style;
+
+ switch (application) {
+ case ColorApplication::BACKGROUND:
+ style = QString("QCheckBox { background-color: rgba(%1, %2); } "
+ "QRadioButton { background-color: rgba(%1, %2); }")
+ .arg(color).arg(opacity);
+ break;
+
+ case ColorApplication::BORDER:
+ style = QString("QCheckBox { border: %1px dashed rgb(%2); } "
+ "QRadioButton { border: %1px dashed rgb(%2); }")
+ .arg(borderWidth).arg(color);
+ break;
+
+ case ColorApplication::TEXT:
+ style = QString("QCheckBox { color: rgb(%1); } "
+ "QRadioButton { color: rgb(%1); }")
+ .arg(color);
+ break;
+
+ case ColorApplication::ALL:
+ style = QString("QCheckBox { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); } "
+ "QRadioButton { background-color: rgba(%1, %2); border: %3px solid rgb(%1); color: rgb(%1); }")
+ .arg(color).arg(opacity).arg(borderWidth);
+ break;
+ }
+
+ return style;
+}
+
+// Main function to get style for a color name and application
+inline QString getStyle(const QString& colorName, const ColorApplication application = ColorApplication::BACKGROUND,
+ const float opacity = 0.4, const int borderWidth = 1)
+{
+ static const std::map<QString, QString> colorValues = {
+ { "Light0", Colors::Light0 },
+ { "Light1", Colors::Light1 },
+ { "Light2", Colors::Light2 },
+ { "Light3", Colors::Light3 },
+ { "Dark0", Colors::Dark0 },
+ { "Dark1", Colors::Dark1 },
+ { "Dark2", Colors::Dark2 },
+ { "Dark3", Colors::Dark3 },
+ { "Red", Colors::Red },
+ { "Red Bright", Colors::RedBright },
+ { "Green", Colors::Green },
+ { "Green Bright", Colors::GreenBright },
+ { "Yellow", Colors::Yellow },
+ { "Yellow Bright", Colors::YellowBright },
+ { "Blue", Colors::Blue },
+ { "Blue Bright", Colors::BlueBright },
+ { "Purple", Colors::Purple },
+ { "Purple Bright", Colors::PurpleBright },
+ { "Aqua", Colors::Aqua },
+ { "Aqua Bright", Colors::AquaBright },
+ { "Orange", Colors::Orange },
+ { "Orange Bright", Colors::OrangeBright }
+ };
+
+ if (const auto it = colorValues.find(colorName); it != colorValues.end()) {
+ return generateStyle(it->second, application, opacity, borderWidth);
+ }
+
+ return {};
+}
+
+// For backward compatibility
+const static std::map<QString, QString> colorStyles = {
+ { "Light0", getStyle("Light0", ColorApplication::BACKGROUND) },
+ { "Light1", getStyle("Light1", ColorApplication::BACKGROUND) },
+ { "Light2", getStyle("Light2", ColorApplication::BACKGROUND) },
+ { "Light3", getStyle("Light3", ColorApplication::BACKGROUND) },
+ { "Dark0", getStyle("Dark0", ColorApplication::BACKGROUND) },
+ { "Dark1", getStyle("Dark1", ColorApplication::BACKGROUND) },
+ { "Dark2", getStyle("Dark2", ColorApplication::BACKGROUND) },
+ { "Dark3", getStyle("Dark3", ColorApplication::BACKGROUND) },
+ { "Red", getStyle("Red", ColorApplication::BACKGROUND) },
+ { "Red Bright", getStyle("Red Bright", ColorApplication::BACKGROUND) },
+ { "Green", getStyle("Green", ColorApplication::BACKGROUND) },
+ { "Green Bright", getStyle("Green Bright", ColorApplication::BACKGROUND) },
+ { "Yellow", getStyle("Yellow", ColorApplication::BACKGROUND) },
+ { "Yellow Bright", getStyle("Yellow Bright", ColorApplication::BACKGROUND) },
+ { "Blue", getStyle("Blue", ColorApplication::BACKGROUND) },
+ { "Blue Bright", getStyle("Blue Bright", ColorApplication::BACKGROUND) },
+ { "Purple", getStyle("Purple", ColorApplication::BACKGROUND) },
+ { "Purple Bright", getStyle("Purple Bright", ColorApplication::BACKGROUND) },
+ { "Aqua", getStyle("Aqua", ColorApplication::BACKGROUND) },
+ { "Aqua Bright", getStyle("Aqua Bright", ColorApplication::BACKGROUND) },
+ { "Orange", getStyle("Orange", ColorApplication::BACKGROUND) },
+ { "Orange Bright", getStyle("Orange Bright", ColorApplication::BACKGROUND) }
+};
+
+}
diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp
new file mode 100644
index 0000000..cb99d27
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp
@@ -0,0 +1,284 @@
+#include "FomodImageViewer.h"
+
+#include "ScaleLabel.h"
+#include "UIHelper.h"
+
+#include <QLabel>
+#include <QScrollArea>
+#include <QtConcurrent/QtConcurrent>
+
+constexpr int PREVIEW_IMAGE_WIDTH = 160;
+constexpr int PREVIEW_IMAGE_HEIGHT = 90;
+
+/*
++----------------------------------------------------------+
+|n/N X |
++---+--------------------------------------------------+---+
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| < | Image | > |
+| | | |
+| | | |
+| | | |
+| | | |
+| | label | |
++------+------+------+---------------------------------+---+
+| | | | ...previews |
+| | | | |
++------+------+------+-------------------------------------+
+*/
+constexpr auto BUTTON_STYLE =
+ "font-size: 16px; font-weight: bold; color: white; background-color: black; padding: 5px; border-radius: 1px solid black;";
+
+FomodImageViewer::FomodImageViewer(QWidget* parent,
+ const QString& fomodPath,
+ const std::shared_ptr<StepViewModel>& activeStep,
+ const std::shared_ptr<PluginViewModel>& activePlugin) : QDialog(parent), mFomodPath(fomodPath),
+ mActiveStep(activeStep),
+ mActivePlugin(activePlugin)
+{
+
+ setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
+ setAttribute(Qt::WA_TranslucentBackground);
+ setStyleSheet("background-color: rgba(0, 0, 0, 150);");
+
+ const QScreen* screen = this->screen();
+ const QRect availableGeometry = screen->availableGeometry();
+ setFixedSize(availableGeometry.width(), availableGeometry.height());
+ move(availableGeometry.x(), availableGeometry.y());
+
+ collectImages();
+ mMainImageWrapper = createSinglePhotoPane(this);
+ mTopBar = createTopBar(this);
+ mPreviewImages = createPreviewImages(this);
+ mCenterRow = createCenterRow(this);
+
+ const auto layout = new QVBoxLayout(this);
+ layout->setContentsMargins(0, 0, 0, 0); // Remove margins
+ layout->setSpacing(0); // Remove spacing between widgets
+ layout->addWidget(mTopBar);
+ layout->addWidget(mCenterRow, 1);
+ layout->addWidget(mPreviewImages);
+ setLayout(layout);
+
+ select(mCurrentIndex);
+
+ setFocusPolicy(Qt::StrongFocus); // so we can receive key events
+}
+
+void FomodImageViewer::collectImages()
+{
+ mLabelsAndImages.clear();
+ for (const auto& groupViewModel : mActiveStep->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ if (pluginViewModel->getImagePath().empty()) {
+ continue;
+ }
+ QString imagePath = UIHelper::getFullImagePath(mFomodPath,
+ QString::fromStdString(pluginViewModel->getImagePath()));
+ mLabelsAndImages.emplace_back(QString::fromStdString(pluginViewModel->getName()), imagePath);
+
+ if (pluginViewModel == mActivePlugin) {
+ mCurrentIndex = static_cast<int>(mLabelsAndImages.size()) - 1;
+ }
+ }
+ }
+}
+
+QWidget* FomodImageViewer::createCenterRow(QWidget* parent)
+{
+ const auto centerRow = new QWidget(parent);
+ const auto layout = new QHBoxLayout(centerRow);
+
+ mBackButton = createBackButton(centerRow);
+ mForwardButton = createForwardButton(centerRow);
+
+ mBackButton->setFocusPolicy(Qt::NoFocus);
+ mForwardButton->setFocusPolicy(Qt::NoFocus);
+
+ layout->addWidget(mBackButton);
+ layout->addWidget(mMainImageWrapper, 1);
+ layout->addWidget(mForwardButton);
+
+ return centerRow;
+}
+
+// ReSharper disable once CppMemberFunctionMayBeStatic
+QWidget* FomodImageViewer::createSinglePhotoPane(QWidget* parent)
+{
+ const auto singlePhotoPane = new QWidget(parent);
+ const auto layout = new QVBoxLayout(singlePhotoPane);
+
+ // const auto [labelText, imagePath] = pair;
+
+ mMainImage = new ScaleLabel(singlePhotoPane);
+ mMainImage->setAlignment(Qt::AlignCenter);
+ layout->addWidget(mMainImage, 1);
+
+ mLabel = new QLabel(singlePhotoPane);
+ // mLabel->setText(labelText);
+ mLabel->setAlignment(Qt::AlignCenter);
+ mLabel->setStyleSheet("color: white; font-size: 20px;");
+ layout->addWidget(mLabel);
+
+ return singlePhotoPane;
+}
+
+QScrollArea* FomodImageViewer::createPreviewImages(QWidget* parent)
+{
+ mImagePanes.clear();
+ const auto previewImages = new QScrollArea(parent);
+ const auto widget = new QWidget(previewImages);
+ const auto layout = new QHBoxLayout(previewImages);
+
+ layout->setContentsMargins(0, 0, 0, 0);
+ layout->setSpacing(0);
+
+ for (int i = 0; i < mLabelsAndImages.size(); i++) {
+ const auto imageLabel = new ScaleLabel(previewImages);
+ imageLabel->setAlignment(Qt::AlignCenter);
+ imageLabel->setFixedSize(PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT);
+ imageLabel->setFocusPolicy(Qt::NoFocus);
+ connect(imageLabel, &ScaleLabel::clicked, this, [this, i] {
+ select(i);
+ });
+ layout->addWidget(imageLabel);
+ mImagePanes.emplace_back(imageLabel);
+
+ // imageLabel->setScalableResource(mLabelsAndImages[i].second);
+ const auto imagePath = mLabelsAndImages[i].second;
+
+ QThreadPool::globalInstance()->start([imageLabel, imagePath]() {
+ QMetaObject::invokeMethod(imageLabel, [imageLabel, imagePath]() {
+ imageLabel->setScalableResource(imagePath);
+ }, Qt::QueuedConnection);
+ });
+ }
+
+ widget->setLayout(layout);
+ previewImages->setFixedHeight(PREVIEW_IMAGE_HEIGHT + 10);
+ previewImages->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ previewImages->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
+ previewImages->setFocusPolicy(Qt::NoFocus);
+
+ previewImages->setWidget(widget);
+ previewImages->setStyleSheet("QScrollArea { border: none; }");
+
+ return previewImages;
+}
+
+QPushButton* FomodImageViewer::createBackButton(QWidget* parent) const
+{
+ const auto backButton = new QPushButton(parent);
+ backButton->setText("<");
+ backButton->setStyleSheet(BUTTON_STYLE);
+ connect(backButton, &QPushButton::clicked, this, &FomodImageViewer::goBack);
+ return backButton;
+}
+
+QPushButton* FomodImageViewer::createForwardButton(QWidget* parent) const
+{
+ const auto forwardButton = new QPushButton(parent);
+ forwardButton->setText(">");
+ forwardButton->
+ setStyleSheet(BUTTON_STYLE);
+ connect(forwardButton, &QPushButton::clicked, this, &FomodImageViewer::goForward);
+ return forwardButton;
+}
+
+QWidget* FomodImageViewer::createTopBar(QWidget* parent)
+{
+ const auto topBar = new QWidget(parent);
+ const auto layout = new QHBoxLayout(topBar);
+
+ // counter, spacer, close button
+ mCounter = new QLabel(topBar);
+ mCounter->setStyleSheet(BUTTON_STYLE);
+ layout->addWidget(mCounter);
+
+ layout->addStretch();
+
+ mCloseButton = createCloseButton(topBar);
+ layout->addWidget(mCloseButton);
+ return topBar;
+}
+
+QPushButton* FomodImageViewer::createCloseButton(QWidget* parent)
+{
+ const auto closeButton = new QPushButton(parent);
+ // const QIcon icon(":/fomod/close");
+ // closeButton->setIcon(icon);
+ closeButton->setText("X");
+ closeButton->setStyleSheet("color: white; background-color: black; padding: 5px; border-radius: 1px solid black;");
+ connect(closeButton, &QPushButton::clicked, this, &FomodImageViewer::close);
+ return closeButton;
+}
+
+void FomodImageViewer::updateCounterText() const
+{
+ mCounter->setText(QString::number(mCurrentIndex + 1) + "/" + QString::number(mLabelsAndImages.size()));
+}
+
+void FomodImageViewer::goBack()
+{
+ if (mCurrentIndex == 0) {
+ return;
+ }
+ select(--mCurrentIndex);
+}
+
+void FomodImageViewer::goForward()
+{
+ if (mCurrentIndex == mLabelsAndImages.size() - 1) {
+ return;
+ }
+ select(++mCurrentIndex);
+}
+
+void FomodImageViewer::select(const int index)
+{
+ if (index < 0 || index >= mLabelsAndImages.size()) {
+ return;
+ }
+
+ // Remove border from previously selected image
+ mCurrentIndex = index; // check for bounds?
+ updateCounterText();
+
+ for (int i = 0; i < mImagePanes.size(); i++) {
+ if (i == mCurrentIndex) {
+ mImagePanes[i]->setStyleSheet("border: 2px solid white;");
+ } else {
+ mImagePanes[i]->setStyleSheet("");
+ }
+ }
+
+ const auto& imagePath = mLabelsAndImages[index].second;
+ const auto& labelText = mLabelsAndImages[index].first;
+ mLabel->setText(labelText);
+ mMainImage->setScalableResource(imagePath);
+}
+
+void FomodImageViewer::keyPressEvent(QKeyEvent* event)
+{
+ switch (event->key()) {
+ case Qt::Key_Left:
+ goBack();
+ break;
+ case Qt::Key_Right:
+ goForward();
+ break;
+ default:
+ QDialog::keyPressEvent(event);
+ }
+}
+
+void FomodImageViewer::showEvent(QShowEvent* event)
+{
+ QDialog::showEvent(event);
+ setFocus();
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h
new file mode 100644
index 0000000..73a06f7
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodImageViewer.h
@@ -0,0 +1,92 @@
+#pragma once
+
+#include "FomodViewModel.h"
+
+#include <QDialog>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QScrollArea>
+
+class ScaleLabel;
+using LabelImagePair = std::pair<QString, QString>;
+
+
+/*
++----------------------------------------------------------+
+|n/N X |
++---+--------------------------------------------------+---+
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| | | |
+| < | Image | > |
+| | | |
+| | | |
+| | | |
+| | | |
+| | label | |
++------+------+------+---------------------------------+---+
+| | | | ...previews |
+| | | | |
++------+------+------+-------------------------------------+
+*/
+
+class FomodImageViewer final : public QDialog {
+ Q_OBJECT
+
+public:
+ explicit FomodImageViewer(QWidget* parent,
+ const QString& fomodPath,
+ const std::shared_ptr<StepViewModel>& activeStep,
+ const std::shared_ptr<PluginViewModel>& activePlugin);
+
+private:
+ void collectImages();
+
+ QWidget* createCenterRow(QWidget* parent);
+
+ QWidget* createSinglePhotoPane(QWidget* parent);
+
+ QScrollArea* createPreviewImages(QWidget* parent);
+
+ QPushButton* createBackButton(QWidget* parent) const;
+
+ QPushButton* createForwardButton(QWidget* parent) const;
+
+ QWidget* createTopBar(QWidget* parent);
+
+ QPushButton* createCloseButton(QWidget* parent);
+
+ void updateCounterText() const;
+
+ void goBack();
+
+ void goForward();
+
+ void select(int index);
+
+ void keyPressEvent(QKeyEvent* event) override;
+
+ void showEvent(QShowEvent* event) override;
+
+ std::vector<LabelImagePair> mLabelsAndImages;
+ std::vector<QWidget*> mImagePanes{};
+ int mCurrentIndex{ 0 };
+
+ QString mFomodPath;
+ const std::shared_ptr<StepViewModel>& mActiveStep;
+ const std::shared_ptr<PluginViewModel>& mActivePlugin;
+
+ QWidget* mCenterRow{ nullptr };
+ QWidget* mTopBar{ nullptr };
+ QWidget* mCloseButton{ nullptr };
+ QPushButton* mBackButton{ nullptr };
+ QPushButton* mForwardButton{ nullptr };
+ QLabel* mCounter{ nullptr };
+ QWidget* mMainImageWrapper{ nullptr };
+ ScaleLabel* mMainImage{ nullptr };
+ QLabel* mLabel{ nullptr };
+ QScrollArea* mPreviewImages{ nullptr };
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp
new file mode 100644
index 0000000..b11674c
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp
@@ -0,0 +1,746 @@
+#include "FomodViewModel.h"
+#include "xml/ModuleConfiguration.h"
+#include "lib/Logger.h"
+
+using GroupCallback = std::function<void(GroupRef)>;
+using PluginCallback = std::function<void(GroupRef, PluginRef)>;
+
+
+/*
+--------------------------------------------------------------------------------
+ Helpers
+--------------------------------------------------------------------------------
+*/
+#pragma region Helpers
+
+bool isRadioLike(GroupRef group)
+{
+ return group->getType() == SelectExactlyOne
+ || (group->getType() == SelectAtMostOne && group->getPlugins().size() > 1);
+}
+
+bool moreThanOneSelected(GroupRef group)
+{
+ auto selectedPlugins = group->getPlugins() | std::views::filter([](const auto& plugin) {
+ return plugin->isSelected();
+ });
+ return std::ranges::distance(selectedPlugins) > 1;
+}
+
+bool anySelected(GroupRef group)
+{
+ return std::ranges::any_of(group->getPlugins(), [](const auto& plugin) { return plugin->isSelected(); });
+}
+
+std::string pluginTypeEnumToString(const PluginTypeEnum type)
+{
+ switch (type) {
+ case PluginTypeEnum::Recommended:
+ return "Recommended";
+ case PluginTypeEnum::Required:
+ return "Required";
+ case PluginTypeEnum::Optional:
+ return "Optional";
+ case PluginTypeEnum::NotUsable:
+ return "NotUsable";
+ case PluginTypeEnum::CouldBeUsable:
+ return "CouldBeUsable";
+ default:
+ return "Unknown";
+ }
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Lifecycle
+--------------------------------------------------------------------------------
+*/
+#pragma region ViewModel Lifecycle
+
+/**
+ * @brief FomodViewModel constructor
+ *
+ * @note DO NOT USE DIRECTLY. We should only use FomodViewModel::create() to create a new instance.
+ * @see FomodViewModel::create
+ *
+ * @param organizer The organizer instance passed from the IInstaller
+ * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file
+ * @param infoFile The FomodInfoFile instance created from the raw info.xml file
+ *
+ * @return A FomodViewModel instance
+ */
+FomodViewModel::FomodViewModel(MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile)
+ : mOrganizer(organizer), mFomodFile(std::move(fomodFile)), mInfoFile(std::move(infoFile)),
+ mConditionTester(organizer),
+ mInfoViewModel(std::make_shared<InfoViewModel>(mInfoFile))
+{
+ mFlags = std::make_shared<FlagMap>();
+}
+
+/**
+ *
+ * @param organizer The organizer instance passed from the IInstaller
+ * @param fomodFile The ModuleConfiguration instance created from the raw ModuleConfiguration.xml file
+ * @param infoFile The FomodInfoFile instance created from the raw info.xml file
+ * @return A shared pointer to the FomodViewModel instance
+ */
+std::shared_ptr<FomodViewModel> FomodViewModel::create(MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile)
+{
+ auto viewModel = std::make_shared<FomodViewModel>(organizer, std::move(fomodFile), std::move(infoFile));
+ if (viewModel->mFlags == nullptr) {
+ viewModel->mFlags = std::make_shared<FlagMap>();
+ }
+ viewModel->createStepViewModels();
+
+ // Handle FOMODs with no steps
+ if (viewModel->mSteps.empty()) {
+ viewModel->mInitialized = true;
+ viewModel->logMessage(INFO, "FOMOD with no steps - initialization complete");
+ return viewModel;
+ }
+
+ viewModel->processPluginConditions(-1); // please dont judge me. ill fix this someday.
+ viewModel->enforceGroupConstraints();
+ viewModel->updateVisibleSteps();
+ viewModel->mInitialized = true;
+ viewModel->mCurrentStepIndex = viewModel->mVisibleStepIndices.front();
+ viewModel->mActiveStep = viewModel->mSteps.at(viewModel->mVisibleStepIndices.front());
+ viewModel->mActivePlugin = viewModel->getFirstPluginForActiveStep();
+ viewModel->getActiveStep()->setVisited(true);
+ viewModel->logMessage(DEBUG, "VIEWMODEL INITIALIZED");
+ viewModel->logMessage(DEBUG, viewModel->toString());
+ return viewModel;
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Traversal Functions
+--------------------------------------------------------------------------------
+*/
+#pragma region Traversal Functions
+
+void FomodViewModel::forEachGroup(const GroupCallback& callback) const
+{
+ for (const auto& stepViewModel : mSteps) {
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ callback(groupViewModel);
+ }
+ }
+}
+
+void FomodViewModel::forEachPlugin(const PluginCallback& callback) const
+{
+ for (const auto& stepViewModel : mSteps) {
+ for (const auto& groupViewModel : stepViewModel->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ callback(groupViewModel, pluginViewModel);
+ }
+ }
+ }
+}
+
+void FomodViewModel::forEachFuturePlugin(const int fromStepIndex, const PluginCallback& callback) const
+{
+ for (int i = fromStepIndex + 1; i < mSteps.size(); ++i) {
+ for (const auto& groupViewModel : mSteps[i]->getGroups()) {
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ callback(groupViewModel, pluginViewModel);
+ }
+ }
+ }
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Initialization
+--------------------------------------------------------------------------------
+*/
+#pragma region Initializers
+void FomodViewModel::createStepViewModels()
+{
+ shared_ptr_list<StepViewModel> stepViewModels;
+
+ // Handle legacy FOMODs with no install steps
+ if (mFomodFile->installSteps.installSteps.empty()) {
+ logMessage(INFO, "No install steps found - creating default step for legacy FOMOD");
+ return;
+ }
+
+ for (int stepIndex = 0; stepIndex < mFomodFile->installSteps.installSteps.size(); ++stepIndex) {
+ const auto& installStep = mFomodFile->installSteps.installSteps[stepIndex];
+ shared_ptr_list<GroupViewModel> groupViewModels;
+
+ for (int groupIndex = 0; groupIndex < installStep.optionalFileGroups.groups.size(); ++groupIndex) {
+ const auto& group = installStep.optionalFileGroups.groups[groupIndex];
+ shared_ptr_list<PluginViewModel> pluginViewModels;
+
+ for (int pluginIndex = 0; pluginIndex < group.plugins.plugins.size(); ++pluginIndex) {
+ const auto& plugin = group.plugins.plugins[pluginIndex];
+ auto pluginViewModel = std::make_shared<PluginViewModel>(std::make_shared<Plugin>(plugin), false, true,
+ pluginIndex);
+
+ pluginViewModel->setStepIndex(stepIndex);
+ pluginViewModel->setGroupIndex(groupIndex);
+ pluginViewModels.emplace_back(pluginViewModel); // Assuming default values for selected and enabled
+ }
+ auto groupViewModel = std::make_shared<GroupViewModel>(std::make_shared<Group>(group), pluginViewModels,
+ groupIndex, stepIndex);
+ if (groupViewModel->getType() == SelectAtMostOne && groupViewModel->getPlugins().size() > 1) {
+ createNonePluginForGroup(groupViewModel);
+ }
+ groupViewModels.emplace_back(groupViewModel);
+ }
+ auto stepViewModel = std::make_shared<StepViewModel>(std::make_shared<InstallStep>(installStep),
+ std::move(groupViewModels), stepIndex);
+ stepViewModels.emplace_back(stepViewModel);
+
+ }
+ mSteps = std::move(stepViewModels);
+}
+
+void FomodViewModel::createNonePluginForGroup(GroupRef group)
+{
+ const auto nonePlugin = std::make_shared<Plugin>();
+ nonePlugin->name = "None";
+ nonePlugin->typeDescriptor.type = PluginTypeEnum::Optional;
+ const int newIndex = static_cast<int>(group->getPlugins().size());
+ const auto nonePluginViewModel = std::make_shared<PluginViewModel>(nonePlugin, true, true, newIndex);
+ group->addPlugin(nonePluginViewModel);
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Group Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Group Constraints
+
+void FomodViewModel::enforceRadioGroupConstraints(GroupRef group) const
+{
+ if (!isRadioLike(group)) {
+ return;
+ }
+
+ logMessage(INFO, "Enforcing group constraints for group " + group->getName());
+
+ if (group->getType() == SelectExactlyOne && group->getPlugins().size() == 1) {
+ logMessage(INFO,
+ "Disabling " + group->getPlugins().at(0)->getName() + " because it's the only plugin.");
+ group->getPlugins().at(0)->setEnabled(false);
+ }
+
+ if (moreThanOneSelected(group)) {
+ logMessage(ERR, "More than one plugin is selected in a SelectExactlyOne group. Deselecting all.");
+ for (const auto& plugin : group->getPlugins()) {
+ plugin->setSelected(false); // don't call toggle here, that'll do the radio stuff.
+ }
+ }
+
+ if (anySelected(group)) {
+ logMessage(INFO, "At least one plugin is selected. Nothing to enforce.");
+ return;
+ }
+
+ // First, try to select the first Recommended plugin
+ for (const auto& plugin : group->getPlugins()) {
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) == PluginTypeEnum::Recommended) {
+ logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first recommended plugin.");
+ togglePlugin(group, plugin, true);
+ return;
+ }
+ }
+
+ // If no Recommended plugin is found, select the first one that isn't NotUsable
+ for (const auto& plugin : group->getPlugins()) {
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) {
+ logMessage(INFO, "Selecting " + plugin->getName() + " because it's the first usable plugin.");
+ togglePlugin(group, plugin, true);
+ return;
+ }
+ }
+}
+
+void FomodViewModel::enforceSelectAllConstraint(GroupRef groupViewModel) const
+{
+ if (groupViewModel->getType() != SelectAll) {
+ return;
+ }
+
+ for (const auto& pluginViewModel : groupViewModel->getPlugins()) {
+ togglePlugin(groupViewModel, pluginViewModel, true);
+ pluginViewModel->setEnabled(false);
+ }
+
+}
+
+void FomodViewModel::enforceSelectAtLeastOneConstraint(GroupRef group) const
+{
+ if (group->getType() != SelectAtLeastOne || group->getPlugins().size() != 1) {
+ return;
+ }
+
+ const auto plugin = group->getPlugins().front();
+ if (mConditionTester.getPluginTypeDescriptorState(plugin->getPlugin(), mFlags) != PluginTypeEnum::NotUsable) {
+ logMessage(DEBUG, "Selecting " + plugin->getName() + " because it's the only plugin in a SelectAtLeastOne.");
+ togglePlugin(group, plugin, true);
+ plugin->setEnabled(false);
+ }
+}
+
+void FomodViewModel::enforceGroupConstraints() const
+{
+ forEachGroup([this](const auto& groupViewModel) {
+ enforceRadioGroupConstraints(groupViewModel);
+ enforceSelectAllConstraint(groupViewModel);
+ enforceSelectAtLeastOneConstraint(groupViewModel);
+ });
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Plugin Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Plugin Constraints
+
+void FomodViewModel::processPlugin(GroupRef group, PluginRef plugin) const
+{
+ if (group->getType() == SelectAll) {
+ return;
+ }
+ const auto typeDescriptor = mConditionTester.getPluginTypeDescriptorState(plugin->plugin, mFlags);
+
+ if (typeDescriptor == plugin->getCurrentPluginType()) {
+ return;
+ }
+
+ logMessage(DEBUG,
+ "Plugin " + plugin->getName() + " in group " + std::to_string(group->getOwnIndex()) + " has changed type from "
+ + pluginTypeEnumToString(plugin->getCurrentPluginType()) + " to " + pluginTypeEnumToString(typeDescriptor));
+
+ plugin->setCurrentPluginType(typeDescriptor);
+
+ const bool isOnlyPlugin = group->getPlugins().size() == 1
+ && (group->getType() == SelectExactlyOne || group->getType() == SelectAtLeastOne);
+
+ // check if step hasVisited, if it hasn't been, set it to unchecked if it's optional.
+ const auto stepNotVisitedYet = !mSteps[group->getStepIndex()]->getHasVisited();
+
+ switch (typeDescriptor) {
+ case PluginTypeEnum::Recommended:
+ plugin->setEnabled(true);
+ if (!plugin->isSelected()) {
+ togglePlugin(group, plugin, true);
+ }
+ break;
+ case PluginTypeEnum::Required:
+ plugin->setEnabled(false);
+ if (!plugin->isSelected()) {
+ togglePlugin(group, plugin, true);
+ }
+ break;
+ case PluginTypeEnum::Optional:
+ if (!isOnlyPlugin) {
+ plugin->setEnabled(true);
+ }
+ // In the case where we're changing flags to make something optional from Recommended, set it back to unchecked.
+ if (plugin->isSelected() & stepNotVisitedYet && group->getType() == SelectAny) {
+ togglePlugin(group, plugin, false);
+ }
+ break;
+ case PluginTypeEnum::NotUsable:
+ plugin->setEnabled(false);
+ if (plugin->isSelected()) {
+ togglePlugin(group, plugin, false);
+ }
+ break;
+ case PluginTypeEnum::CouldBeUsable:
+ plugin->setEnabled(true);
+ break;
+ default: ;
+ }
+}
+
+void FomodViewModel::processPluginConditions(const int fromStepIndex) const
+{
+ // We only want to update plugins that haven't been seen yet. Otherwise, we could undo manual selections by the user.
+ if (fromStepIndex >= 0) {
+ logMessage(DEBUG, "Processing plugins from step " + std::to_string(fromStepIndex));
+ forEachFuturePlugin(fromStepIndex, [this](const auto& groupViewModel, const auto& pluginViewModel) {
+ processPlugin(groupViewModel, pluginViewModel);
+ });
+ } else {
+ forEachPlugin([this](const auto& groupViewModel, const auto& pluginViewModel) {
+ processPlugin(groupViewModel, pluginViewModel);
+ });
+ }
+}
+
+void FomodViewModel::setFlagForPluginState(PluginRef plugin) const
+{
+ if (plugin->isSelected()) {
+ mFlags->setFlagsForPlugin(plugin);
+ } else {
+ mFlags->unsetFlagsForPlugin(plugin);
+ }
+}
+
+/*
+ * In an exclusive group, this gets called for the deselected plugin and then the selected plugin.
+ * So if we're unselecting modB to select modA, we will get calls like
+ * togglePlugin(group, modB, false)
+ * togglePlugin(group, modA, true)
+ */
+bool FomodViewModel::togglePlugin(GroupRef group, PluginRef plugin, const bool selected) const
+{
+ if (plugin->isSelected() == selected) {
+ logMessage(DEBUG, "Plugin " + plugin->getName() + " is already " + (selected ? "selected" : "deselected"));
+ return false;
+ }
+
+ // Disable other radio options first.
+ if (selected && isRadioLike(group)) {
+ for (const auto& otherPlugin : group->getPlugins()) {
+ if (otherPlugin != plugin && plugin->isSelected()) {
+ logMessage(DEBUG,
+ "Deselecting " + otherPlugin->getName() + " because " + plugin->getName() + " was selected.");
+ otherPlugin->setSelected(false);
+ setFlagForPluginState(otherPlugin);
+ }
+ }
+ }
+
+ const auto stepIndex = group->getStepIndex();
+
+ logMessage(INFO, "Toggling " + plugin->getName() + " to " + (selected ? "true" : "false"));
+ plugin->setSelected(selected);
+ setFlagForPluginState(plugin);
+
+ if (mInitialized) {
+ mActivePlugin = plugin;
+ }
+ processPluginConditions(stepIndex);
+ updateVisibleSteps();
+ return true;
+}
+
+void FomodViewModel::markManuallySet(PluginRef plugin)
+{
+ plugin->manuallySet = true;
+}
+
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Step Constraints
+--------------------------------------------------------------------------------
+*/
+#pragma region Step Constraints
+
+void FomodViewModel::updateVisibleSteps() const
+{
+ mVisibleStepIndices.clear();
+ mFlags->clearAll();
+
+ for (int i = 0; i < mSteps.size(); ++i) {
+ if (i == 0) {
+ rebuildConditionFlagsForStep(i);
+ }
+
+ // This also depends on previous flags that may have set this particular flag.
+ if (mConditionTester.isStepVisible(mFlags, mSteps[i]->getVisibilityConditions(), i, mSteps)) {
+ mVisibleStepIndices.push_back(i);
+ rebuildConditionFlagsForStep(i);
+ }
+ }
+ if (mFlags->getFlagCount() > 0) {
+ logMessage(DEBUG, mFlags->toString());
+ }
+}
+
+void FomodViewModel::rebuildConditionFlagsForStep(const int stepIndex) const
+{
+ for (const auto& group : mSteps[stepIndex]->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ setFlagForPluginState(plugin);
+ }
+ }
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Navigation/UI
+--------------------------------------------------------------------------------
+*/
+#pragma region Navigation/UI
+
+void FomodViewModel::stepBack()
+{
+ if (mSteps.empty()) {
+ return; // No steps to move back to
+ }
+
+ logMessage(DEBUG, "Stepping back from step " + std::to_string(mCurrentStepIndex));
+ const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex);
+ if (it != mVisibleStepIndices.end() && it != mVisibleStepIndices.begin()) {
+ mCurrentStepIndex = *std::prev(it);
+ mActiveStep = mSteps[mCurrentStepIndex];
+ mActivePlugin = getFirstPluginForActiveStep();
+ }
+ logMessage(DEBUG, "Stepped back to step " + std::to_string(mCurrentStepIndex));
+}
+
+void FomodViewModel::stepForward()
+{
+ if (mSteps.empty()) {
+ return; // No steps to move forward to
+ }
+
+ logMessage(DEBUG, "Stepping forward from step " + std::to_string(mCurrentStepIndex));
+ const auto it = std::ranges::find(mVisibleStepIndices, mCurrentStepIndex);
+ if (it != mVisibleStepIndices.end() && std::next(it) != mVisibleStepIndices.end()) {
+ mCurrentStepIndex = *std::next(it);
+ mActiveStep = mSteps[mCurrentStepIndex];
+ mActivePlugin = getFirstPluginForActiveStep();
+ }
+ mActiveStep->setVisited(true);
+ logMessage(DEBUG, "Stepped forward to step " + std::to_string(mCurrentStepIndex));
+}
+
+bool FomodViewModel::isLastVisibleStep() const
+{
+ if (mSteps.empty()) {
+ return true; // Legacy FOMODs are always "last step"
+ }
+ return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.back();
+}
+
+bool FomodViewModel::isFirstVisibleStep() const
+{
+ if (mSteps.empty()) {
+ return true; // Legacy FOMODs are always "first step"
+ }
+ return !mVisibleStepIndices.empty() && mCurrentStepIndex == mVisibleStepIndices.front();
+}
+
+void FomodViewModel::preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath)
+{
+ mFileInstaller = std::make_shared<
+ FileInstaller>(mOrganizer, fomodPath, tree, std::move(mFomodFile), mFlags, mSteps);
+}
+
+
+std::string FomodViewModel::getDisplayImage() const
+{
+ // if the active plugin has an image, return it
+ if (mActivePlugin && !mActivePlugin->getImagePath().empty()) {
+ return mActivePlugin->getImagePath();
+ }
+ return mCurrentStepIndex == 0 ? mFomodFile->moduleImage.path : "";
+}
+
+std::shared_ptr<PluginViewModel> FomodViewModel::getFirstPluginForActiveStep() const
+{
+ if (!mActiveStep) {
+ return nullptr;
+ }
+
+ const auto& groups = mActiveStep->getGroups();
+ if (groups.empty()) {
+ return nullptr;
+ }
+
+ const auto& plugins = groups.front()->getPlugins();
+ if (plugins.empty()) {
+ return nullptr;
+ }
+
+ return plugins.front();
+}
+#pragma endregion
+
+/*
+--------------------------------------------------------------------------------
+ Utility
+--------------------------------------------------------------------------------
+*/
+#pragma region Utility
+std::string FomodViewModel::toString() const
+{
+ std::string viewModel = "\n";
+ for (const auto& step : mSteps) {
+
+ const auto isVisible = std::ranges::find(mVisibleStepIndices, step->getOwnIndex()) != mVisibleStepIndices.end();
+ viewModel += "Step " + std::to_string(step->getOwnIndex()) + ": " + step->getName() + "[Visible: " +
+ std::to_string(isVisible) + "]\n";
+
+ for (const auto& group : step->getGroups()) {
+
+ viewModel += "\tGroup " + std::to_string(group->getOwnIndex()) + ": " + group->getName() + "\n";
+
+ for (const auto& plugin : group->getPlugins()) {
+ viewModel += "\t\tPlugin: " + plugin->getName() + "[Selected: " + (plugin->isSelected()
+ ? "TRUE"
+ : "FALSE") + "]\n";
+ }
+ }
+ }
+ viewModel += "\n";
+ std::ostringstream oss;
+ std::ranges::transform(mVisibleStepIndices,
+ std::ostream_iterator<std::string>(oss, ", "),
+ [](const int i) { return std::to_string(i); });
+
+ std::string stepList = oss.str();
+ stepList.erase(stepList.length() - 2);
+
+ viewModel += "Visible Steps: [" + stepList + "]\n";
+ viewModel += mFlags->toString();
+ return viewModel;
+}
+
+
+void FomodViewModel::resetToDefaults()
+{
+ logMessage(INFO, "Resetting all choices to author defaults");
+
+ // Clear all flags first
+ mFlags->clearAll();
+
+ // Reset all plugins to deselected and clear visited states
+ for (const auto& step : mSteps) {
+ step->setVisited(false);
+ for (const auto& group : step->getGroups()) {
+ for (const auto& plugin : group->getPlugins()) {
+ plugin->setSelected(false);
+ plugin->setEnabled(true);
+ plugin->manuallySet = false;
+ plugin->setCurrentPluginType(PluginTypeEnum::UNKNOWN);
+ }
+ }
+ }
+
+ // Re-run the initial constraint enforcement to restore author defaults
+ processPluginConditions(-1);
+ enforceGroupConstraints();
+ updateVisibleSteps();
+
+ // Reset to first step
+ mCurrentStepIndex = mVisibleStepIndices.empty() ? 0 : mVisibleStepIndices.front();
+ mActiveStep = mSteps.empty() ? nullptr : mSteps.at(mCurrentStepIndex);
+ mActivePlugin = getFirstPluginForActiveStep();
+ if (mActiveStep) {
+ mActiveStep->setVisited(true);
+ }
+
+ logMessage(DEBUG, "Reset complete. Current state:\n" + toString());
+}
+
+void FomodViewModel::selectFromJson(nlohmann::json json) const
+{
+ const auto jsonSteps = json["steps"];
+ const auto stepCount = jsonSteps.size();
+
+ for (int stepIndex = 0; stepIndex < stepCount; ++stepIndex) {
+
+ if (stepIndex > mSteps.size() - 1) {
+ logMessage(ERR, "Step index " + std::to_string(stepIndex) + " is out of bounds.");
+ continue;
+ }
+
+ const auto currentStep = mSteps[stepIndex];
+ const auto step = jsonSteps[stepIndex];
+ const auto groupCount = step["groups"].size();
+
+ logMessage(DEBUG, "Selecting plugins for step " + std::to_string(stepIndex));
+ logMessage(DEBUG, "There are " + std::to_string(groupCount) + " groups.");
+
+ for (int groupIndex = 0; groupIndex < groupCount; ++groupIndex) {
+ if (groupIndex > currentStep->getGroups().size() - 1) {
+ logMessage(ERR, "Group index " + std::to_string(groupIndex) + " is out of bounds.");
+ continue;
+ }
+
+ const auto group = step["groups"][groupIndex];
+ const auto currentGroup = currentStep->getGroups()[groupIndex];
+
+ for (const auto jsonPlugin : group["plugins"]) {
+
+ const auto& allPlugins = currentGroup->getPlugins();
+ const auto searchName = jsonPlugin.get<std::string>();
+
+ logMessage(DEBUG, "Looking for plugin " + searchName);
+
+ const auto currentPlugin = std::ranges::find_if(allPlugins,
+ [searchName](PluginRef p) {
+ return p->getName() == searchName;
+ });
+
+ if (currentPlugin == allPlugins.end()) {
+ logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName());
+ continue;
+ }
+
+ if ((*currentPlugin)->isSelected()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is already selected.");
+ continue;
+ }
+ logMessage(DEBUG, "Toggle plugin " + searchName + " to selected.");
+ if (!(*currentPlugin)->isEnabled()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is not enabled.");
+ continue;
+ }
+ togglePlugin(currentGroup, *currentPlugin, true);
+ }
+
+ if (!group.contains("deselected")) {
+ continue;
+ }
+
+ // Do the opposite of the above. For unchecked plugins, disable them.
+ for (const auto jsonPlugin : group["deselected"]) {
+
+ const auto& allPlugins = currentGroup->getPlugins();
+ const auto searchName = jsonPlugin.get<std::string>();
+
+ logMessage(DEBUG, "Looking for plugin to disable: " + searchName);
+
+ const auto currentPlugin = std::ranges::find_if(allPlugins,
+ [searchName](PluginRef p) {
+ return p->getName() == searchName;
+ });
+
+ if (currentPlugin == allPlugins.end()) {
+ logMessage(DEBUG, "Plugin " + searchName + " not found in group " + currentGroup->getName());
+ continue;
+ }
+
+ if (!(*currentPlugin)->isSelected()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is already deselected.");
+ continue;
+ }
+ logMessage(DEBUG, "Toggle plugin " + searchName + " to deselected.");
+ if (!(*currentPlugin)->isEnabled()) {
+ logMessage(DEBUG, "Plugin " + searchName + " is not enabled.");
+ continue;
+ }
+ togglePlugin(currentGroup, *currentPlugin, false);
+ (*currentPlugin)->manuallySet = true; // To preserve this state when serializing JSON.
+ }
+ }
+ }
+}
+#pragma endregion
diff --git a/libs/installer_fomod_plus/installer/ui/FomodViewModel.h b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h
new file mode 100644
index 0000000..f0ce070
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/FomodViewModel.h
@@ -0,0 +1,154 @@
+#pragma once
+
+#include <imoinfo.h>
+#include <string>
+
+#include "lib/ConditionTester.h"
+#include "lib/FlagMap.h"
+#include "lib/FileInstaller.h"
+#include "lib/ViewModels.h"
+#include "xml/FomodInfoFile.h"
+
+/*
+--------------------------------------------------------------------------------
+ Info
+--------------------------------------------------------------------------------
+*/
+class InfoViewModel {
+public:
+ explicit InfoViewModel(const std::unique_ptr<FomodInfoFile>& infoFile)
+ {
+ if (infoFile) {
+ // Copy the necessary members from FomodInfoFile to InfoViewModel
+ mName = infoFile->getName();
+ mVersion = infoFile->getVersion();
+ mAuthor = infoFile->getAuthor();
+ mWebsite = infoFile->getWebsite();
+ }
+ }
+
+ // Accessor methods
+ [[nodiscard]] std::string getName() const { return mName; }
+ [[nodiscard]] std::string getVersion() const { return mVersion; }
+ [[nodiscard]] std::string getAuthor() const { return mAuthor; }
+ [[nodiscard]] std::string getWebsite() const { return mWebsite; }
+
+private:
+ std::string mName;
+ std::string mVersion;
+ std::string mAuthor;
+ std::string mWebsite;
+};
+
+/*
+--------------------------------------------------------------------------------
+ View Model
+--------------------------------------------------------------------------------
+*/
+class FomodViewModel {
+public:
+ FomodViewModel(
+ MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile);
+
+ static std::shared_ptr<FomodViewModel> create(
+ MOBase::IOrganizer* organizer,
+ std::unique_ptr<ModuleConfiguration> fomodFile,
+ std::unique_ptr<FomodInfoFile> infoFile);
+
+ void forEachGroup(const std::function<void(GroupRef)>& callback) const;
+
+ void forEachPlugin(const std::function<void(GroupRef, PluginRef)>& callback) const;
+
+ void forEachFuturePlugin(int fromStepIndex, const std::function<void(GroupRef, PluginRef)>& callback) const;
+
+ void selectFromJson(nlohmann::json json) const;
+
+ void resetToDefaults();
+
+ [[nodiscard]] std::shared_ptr<PluginViewModel> getFirstPluginForActiveStep() const;
+
+ // Steps
+ [[nodiscard]] shared_ptr_list<StepViewModel> getSteps() const { return mSteps; }
+ [[nodiscard]] StepRef getActiveStep() const { return mActiveStep; }
+ [[nodiscard]] int getCurrentStepIndex() const { return mCurrentStepIndex; }
+ [[deprecated]] void setCurrentStepIndex(const int index) { mCurrentStepIndex = index; }
+
+ void updateVisibleSteps() const;
+
+ void rebuildConditionFlagsForStep(int stepIndex) const;
+
+ void preinstall(const std::shared_ptr<MOBase::IFileTree>& tree, const QString& fomodPath);
+
+ std::shared_ptr<FileInstaller> getFileInstaller() { return mFileInstaller; }
+
+ std::string getDisplayImage() const;
+
+ // Plugins
+ [[nodiscard]] PluginRef getActivePlugin() const { return mActivePlugin; }
+
+ // Info
+ [[nodiscard]] std::shared_ptr<InfoViewModel> getInfoViewModel() const { return mInfoViewModel; }
+
+ // Interactions
+ void stepBack();
+
+ void stepForward();
+
+ bool isLastVisibleStep() const;
+
+ bool isFirstVisibleStep() const;
+
+ bool togglePlugin(const GroupRef, const PluginRef, bool selected) const;
+
+ bool ctrlTogglePlugin(const GroupRef, const PluginRef, bool selected) const;
+
+ void setActivePlugin(const PluginRef plugin) const { mActivePlugin = plugin; }
+
+ static void markManuallySet(PluginRef plugin);
+
+private:
+ Logger& log = Logger::getInstance();
+ MOBase::IOrganizer* mOrganizer = nullptr;
+ std::unique_ptr<ModuleConfiguration> mFomodFile;
+ std::unique_ptr<FomodInfoFile> mInfoFile;
+ std::shared_ptr<FlagMap> mFlags{ nullptr };
+ ConditionTester mConditionTester;
+ std::shared_ptr<InfoViewModel> mInfoViewModel;
+ std::vector<std::shared_ptr<StepViewModel> > mSteps;
+ mutable std::shared_ptr<PluginViewModel> mActivePlugin{ nullptr };
+ mutable std::shared_ptr<StepViewModel> mActiveStep{ nullptr };
+ mutable std::vector<int> mVisibleStepIndices;
+ std::shared_ptr<FileInstaller> mFileInstaller{ nullptr };
+ bool mInitialized{ false };
+
+ void createStepViewModels();
+
+ void setFlagForPluginState(const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ static void createNonePluginForGroup(const std::shared_ptr<GroupViewModel>& group);
+
+ void processPlugin(const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin) const;
+
+ void enforceRadioGroupConstraints(const std::shared_ptr<GroupViewModel>& group) const;
+
+ void enforceSelectAllConstraint(const std::shared_ptr<GroupViewModel>& groupViewModel) const;
+
+ void enforceSelectAtLeastOneConstraint(const std::shared_ptr<GroupViewModel>& group) const;
+
+ void enforceGroupConstraints() const;
+
+ void processPluginConditions(int fromStepIndex) const;
+
+ // Indices
+ int mCurrentStepIndex{ 0 };
+
+ void logMessage(const LogLevel level, const std::string& message) const
+ {
+ log.logMessage(level, "[VIEWMODEL] " + message);
+ }
+
+ std::string toString() const;
+};
diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp
new file mode 100644
index 0000000..3a33640
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp
@@ -0,0 +1,120 @@
+#include "ScaleLabel.h"
+#include <QResizeEvent>
+#include <iostream>
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h
+static bool isResourceMovie(const QString& path)
+{
+ const auto formats = QMovie::supportedFormats();
+ return std::ranges::any_of(formats, [&path](const QByteArray& format) {
+ return path.endsWith("." + QString::fromUtf8(format));
+ });
+}
+
+void ScaleLabel::setScalableResource(const QString& path)
+{
+ if (const auto m = movie()) {
+ setMovie(nullptr);
+ delete m;
+ mOriginalMovieSize = QSize();
+ }
+ if (!pixmap().isNull()) {
+ setPixmap(QPixmap());
+ mUnscaledImage = QImage();
+ }
+
+ if (path.isEmpty()) {
+ return;
+ }
+
+ if (isResourceMovie(path)) {
+ setScalableMovie(path);
+ } else {
+ setScalableImage(path);
+ }
+}
+
+void ScaleLabel::setStatic(const bool isStatic)
+{
+ misStatic = isStatic;
+
+ if (const auto m = movie()) {
+ if (isStatic) {
+ m->stop();
+ } else {
+ m->start();
+ }
+ }
+}
+
+void ScaleLabel::setScalableMovie(const QString& path)
+{
+ const auto m = new QMovie(path);
+ if (!m->isValid()) {
+ qWarning(">%s< is an invalid movie. Reason: %s", qUtf8Printable(path),
+ m->lastErrorString().toStdString().c_str());
+ delete m;
+ return;
+ }
+
+ m->setParent(this);
+ setMovie(m);
+ m->start();
+ m->stop();
+ mOriginalMovieSize = m->currentImage().size();
+
+ m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio));
+ if (!misStatic) {
+ m->start();
+ }
+ mHasResource = true;
+}
+
+void ScaleLabel::setScalableImage(const QString& path)
+{
+ if (const QImage image(path); image.isNull()) {
+ qWarning(">%s< is a null image", qUtf8Printable(path));
+ } else {
+ mUnscaledImage = image;
+ setPixmap(QPixmap::fromImage(image).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ mHasResource = true;
+ }
+}
+
+void ScaleLabel::resizeEvent(QResizeEvent* event)
+{
+ if (const auto m = movie()) {
+ m->stop();
+ m->setScaledSize(mOriginalMovieSize.scaled(event->size(), Qt::KeepAspectRatio));
+ m->start();
+
+ // We can't just skip the start() above since that is what triggers the label to
+ // resize the movie The only way to resize the movie but keep it paused is to start
+ // and then re-stop it
+ if (misStatic) {
+ m->stop();
+ }
+ }
+ if (const auto p = pixmap(); !p.isNull()) {
+ setPixmap(
+ QPixmap::fromImage(mUnscaledImage).scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ }
+}
+
+void ScaleLabel::showEvent(QShowEvent* event)
+{
+ QLabel::showEvent(event);
+
+ if (const auto m = movie()) {
+ m->stop();
+ m->setScaledSize(mOriginalMovieSize.scaled(size(), Qt::KeepAspectRatio));
+ m->start();
+
+ if (misStatic) {
+ m->stop();
+ }
+ }
+ if (const auto p = pixmap(); !p.isNull()) {
+ setPixmap(QPixmap::fromImage(mUnscaledImage).scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ }
+} \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/ScaleLabel.h b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h
new file mode 100644
index 0000000..f4fb5f2
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/ScaleLabel.h
@@ -0,0 +1,45 @@
+#pragma once
+
+// Taken from https://github.com/ModOrganizer2/modorganizer-installer_fomod/blob/master/src/scalelabel.h
+#include <QLabel>
+#include <QMouseEvent>
+#include <QMovie>
+
+
+class ScaleLabel final : public QLabel {
+ Q_OBJECT
+
+public:
+ explicit ScaleLabel(QWidget* parent = nullptr) : QLabel(parent)
+ {
+ setCursor(Qt::PointingHandCursor);
+ }
+
+ void setScalableResource(const QString& path);
+ void setStatic(bool isStatic);
+ [[nodiscard]] bool hasResource() const { return mHasResource; }
+
+signals:
+ void clicked();
+
+protected:
+ void mousePressEvent(QMouseEvent* event) override
+ {
+ if (event->button() == Qt::LeftButton) {
+ emit clicked();
+ }
+ QLabel::mousePressEvent(event);
+ }
+
+ void resizeEvent(QResizeEvent* event) override;
+ void showEvent(QShowEvent* event) override;
+
+private:
+ void setScalableMovie(const QString& path);
+ void setScalableImage(const QString& path);
+
+ QImage mUnscaledImage;
+ QSize mOriginalMovieSize;
+ bool mHasResource = false;
+ bool misStatic = false;
+}; \ No newline at end of file
diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.cpp b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp
new file mode 100644
index 0000000..2312fd1
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/UIHelper.cpp
@@ -0,0 +1,95 @@
+#include "UIHelper.h"
+
+#include <qcoreevent.h>
+#include <QDir>
+#include <QMouseEvent>
+
+HoverEventFilter::HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent)
+ : QObject(parent), mPlugin(plugin) {}
+
+bool HoverEventFilter::eventFilter(QObject* obj, QEvent* event)
+{
+ if (event->type() == QEvent::HoverEnter) {
+ emit hovered(mPlugin);
+ return true;
+ }
+ return QObject::eventFilter(obj, event);
+}
+
+CtrlClickEventFilter::CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QObject* parent)
+ : QObject(parent), mPlugin(plugin), mGroup(group) {}
+
+bool CtrlClickEventFilter::eventFilter(QObject* obj, QEvent* event)
+{
+ if (event->type() == QEvent::MouseButtonPress) {
+ const QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
+ if (mouseEvent->button() == Qt::LeftButton &&
+ mouseEvent->modifiers() & Qt::ControlModifier) {
+ // TODO: Add Ctrl+click handler logic here
+ std::cout << "Ctrl+click detected on plugin: " << mPlugin->getName() << " in group: " << mGroup->getName() << std::endl;
+ // For now, just fall through to default behavior
+ }
+ }
+ return QObject::eventFilter(obj, event);
+}
+
+QPushButton* UIHelper::createButton(const QString& text, QWidget* parent = nullptr)
+{
+ const auto button = new QPushButton(text, parent);
+ return button;
+}
+
+QLabel* UIHelper::createLabel(const QString& text, QWidget* parent = nullptr)
+{
+ const auto label = new QLabel(text, parent);
+ return label;
+}
+
+QLabel* UIHelper::createHyperlink(const QString& url, QWidget* parent = nullptr)
+{
+ if (url.isEmpty() || !QUrl(url).isValid()) {
+ return createLabel(url, parent);
+ }
+ const auto label = new QLabel(url, parent);
+ const QString hyperlink = QString("<a href=\"%1\">%2</a>").arg(url, "Link");
+ label->setText(hyperlink);
+ label->setOpenExternalLinks(true);
+ label->setTextFormat(Qt::RichText);
+ return label;
+}
+
+QString UIHelper::getFullImagePath(const QString& fomodPath, const QString& imagePath)
+{
+ return QDir::tempPath() + "/" + fomodPath + "/" + imagePath;
+}
+
+void UIHelper::setGlobalAlignment(QBoxLayout* layout, const Qt::Alignment alignment)
+{
+ for (int i = 0; i < layout->count(); ++i) {
+ if (const QLayoutItem* item = layout->itemAt(i); item->widget()) {
+ layout->setAlignment(item->widget(), alignment);
+ }
+ }
+}
+
+void UIHelper::setDebugBorders(QWidget* widget)
+{
+ widget->setStyleSheet("border: 1px solid red;");
+ for (auto* child : widget->findChildren<QWidget*>()) {
+ child->setStyleSheet("border: 1px solid red;");
+ }
+}
+
+void UIHelper::reduceLabelPadding(const QLayout* layout)
+{
+ for (int i = 0; i < layout->count(); ++i) {
+ const QLayoutItem* item = layout->itemAt(i);
+ if (QWidget* widget = item->widget()) {
+ if (const auto label = qobject_cast<QLabel*>(widget)) {
+ label->setContentsMargins(0, 0, 0, 0);
+ label->setStyleSheet("padding: 0px; margin: 0px;");
+ }
+ }
+ }
+}
diff --git a/libs/installer_fomod_plus/installer/ui/UIHelper.h b/libs/installer_fomod_plus/installer/ui/UIHelper.h
new file mode 100644
index 0000000..1007e7c
--- /dev/null
+++ b/libs/installer_fomod_plus/installer/ui/UIHelper.h
@@ -0,0 +1,80 @@
+#pragma once
+
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QLabel>
+
+#include "FomodViewModel.h"
+
+class HoverEventFilter final : public QObject {
+ Q_OBJECT
+
+public:
+ explicit HoverEventFilter(const std::shared_ptr<PluginViewModel>& plugin, QObject* parent = nullptr);
+
+signals:
+ void hovered(const std::shared_ptr<PluginViewModel>& plugin);
+
+protected:
+ bool eventFilter(QObject* obj, QEvent* event) override;
+
+private:
+ std::shared_ptr<PluginViewModel> mPlugin;
+};
+
+class CtrlClickEventFilter final : public QObject {
+ Q_OBJECT
+
+public:
+ explicit CtrlClickEventFilter(const std::shared_ptr<PluginViewModel>& plugin,
+ const std::shared_ptr<GroupViewModel>& group, QObject* parent = nullptr);
+
+signals:
+ void ctrlClicked(bool selected, const std::shared_ptr<GroupViewModel>& group,
+ const std::shared_ptr<PluginViewModel>& plugin);
+
+protected:
+ bool eventFilter(QObject* obj, QEvent* event) override;
+
+private:
+ std::shared_ptr<PluginViewModel> mPlugin;
+ std::shared_ptr<GroupViewModel> mGroup;
+};
+
+
+namespace UiConstants {
+constexpr int WINDOW_MIN_WIDTH = 900;
+constexpr int WINDOW_MIN_HEIGHT = 600;
+}
+
+class UIHelper {
+public:
+ /*
+ --------------------------------------------------------------------------------
+ Widgets & Events
+ --------------------------------------------------------------------------------
+ */
+ static QPushButton* createButton(const QString& text, QWidget* parent);
+
+ static QLabel* createLabel(const QString& text, QWidget* parent);
+
+ static QLabel* createHyperlink(const QString& url, QWidget* parent);
+
+ /*
+ --------------------------------------------------------------------------------
+ Helpers
+ --------------------------------------------------------------------------------
+ */
+ static QString getFullImagePath(const QString& fomodPath, const QString& imagePath);
+
+ static void setGlobalAlignment(QBoxLayout* layout, Qt::Alignment alignment);
+
+ static void reduceLabelPadding(const QLayout* layout);
+
+ /*
+ --------------------------------------------------------------------------------
+ Development
+ --------------------------------------------------------------------------------
+ */
+ static void setDebugBorders(QWidget* widget);
+}; \ No newline at end of file