diff options
Diffstat (limited to 'libs/installer_fomod_plus/installer/ui')
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/ClickableWidget.h | 24 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/Colors.h | 155 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/FomodImageViewer.cpp | 284 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/FomodImageViewer.h | 92 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/FomodViewModel.cpp | 746 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/FomodViewModel.h | 154 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/ScaleLabel.cpp | 120 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/ScaleLabel.h | 45 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/UIHelper.cpp | 95 | ||||
| -rw-r--r-- | libs/installer_fomod_plus/installer/ui/UIHelper.h | 80 |
10 files changed, 1795 insertions, 0 deletions
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 |
