diff options
| author | Jeremy Rimpo <jrim@rimpo.org> | 2019-07-03 10:24:14 -0500 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-07-03 10:24:14 -0500 |
| commit | efec72a85a351f53880bffdba2438f6802d4f9ab (patch) | |
| tree | 94cf5dd724a16f29b458776ef5a83477ccfe2a52 /src | |
| parent | 1822c1dc655e60c7693b528004ed715305df45f5 (diff) | |
| parent | 7fe637ce4421e0c6d6ee6b103db5fcc4ef676c25 (diff) | |
Merge pull request #782 from isanae/modinfodialog-rework
Mod info dialog rework
Diffstat (limited to 'src')
51 files changed, 8325 insertions, 3483 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 645a5a73..929ee296 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -54,6 +54,14 @@ SET(organizer_SRCS modlist.cpp modidlineedit.cpp modinfodialog.cpp + modinfodialogcategories.cpp + modinfodialogconflicts.cpp + modinfodialogesps.cpp + modinfodialogfiletree.cpp + modinfodialogimages.cpp + modinfodialognexus.cpp + modinfodialogtab.cpp + modinfodialogtextfiles.cpp modinfo.cpp modinfobackup.cpp modinfoforeign.cpp @@ -118,6 +126,9 @@ SET(organizer_SRCS filterwidget.cpp statusbar.cpp apiuseraccount.cpp + filerenamer.cpp + texteditor.cpp + expanderwidget.cpp shared/windows_error.cpp shared/error_report.cpp @@ -151,6 +162,15 @@ SET(organizer_HDRS modlist.h modidlineedit.h modinfodialog.h + modinfodialogcategories.h + modinfodialogconflicts.h + modinfodialogesps.h + modinfodialogfiletree.h + modinfodialogfwd.h + modinfodialogimages.h + modinfodialognexus.h + modinfodialogtab.h + modinfodialogtextfiles.h modinfo.h modinfobackup.h modinfoforeign.h @@ -208,7 +228,6 @@ SET(organizer_HDRS instancemanager.h usvfsconnector.h eventfilter.h - descriptionpage.h moshortcut.h listdialog.h lcdnumber.h @@ -217,6 +236,9 @@ SET(organizer_HDRS filterwidget.h statusbar.h apiuseraccount.h + filerenamer.h + texteditor.h + expanderwidget.h shared/windows_error.h shared/error_report.h @@ -345,7 +367,6 @@ set(locking set(modinfo modinfo modinfobackup - modinfodialog modinfoforeign modinfooverwrite modinforegular @@ -353,6 +374,18 @@ set(modinfo modinfowithconflictinfo ) +set(modinfo\\dialog + modinfodialog + modinfodialogcategories + modinfodialogconflicts + modinfodialogesps + modinfodialogfiletree + modinfodialogfwd + modinfodialogimages + modinfodialognexus + modinfodialogtab + modinfodialogtextfiles +) set(modlist modlist modlistsortproxy @@ -400,8 +433,9 @@ set(utilities ) set(widgets - descriptionpage + expanderwidget genericicondelegate + filerenamer filterwidget icondelegate lcdnumber @@ -411,11 +445,12 @@ set(widgets modidlineedit noeditdelegate qtgroupingproxy + texteditor viewmarkingscrollbar ) set(src_filters - application core browser dialogs downloads executables locking modinfo + application core browser dialogs downloads executables locking modinfo modinfo\\dialog modlist plugins previews profiles settings utilities widgets ) diff --git a/src/bbcode.cpp b/src/bbcode.cpp index 3475f1b2..9f064106 100644 --- a/src/bbcode.cpp +++ b/src/bbcode.cpp @@ -174,7 +174,7 @@ private: "<a href=\"\\1\">\\1</a>");
m_TagMap["url="] = std::make_pair(QRegExp("\\[url=([^\\]]*)\\](.*)\\[/url\\]"),
"<a href=\"\\1\">\\2</a>");
- m_TagMap["img"] = std::make_pair(QRegExp("\\[img\\](.*)\\[/img\\]"),
+ m_TagMap["img"] = std::make_pair(QRegExp("\\[img(?:\\s*width=\\d+\\s*,?\\s*height=\\d+)?\\](.*)\\[/img\\]"),
"<img src=\"\\1\">");
m_TagMap["img="] = std::make_pair(QRegExp("\\[img=([^\\]]*)\\](.*)\\[/img\\]"),
"<img src=\"\\2\" alt=\"\\1\">");
diff --git a/src/descriptionpage.h b/src/descriptionpage.h deleted file mode 100644 index f6158ee0..00000000 --- a/src/descriptionpage.h +++ /dev/null @@ -1,28 +0,0 @@ -#include <QWebEnginePage> - -#ifndef DESCRIPTIONPAGE_H -#define DESCRIPTIONPAGE_H - -class DescriptionPage : public QWebEnginePage -{ - Q_OBJECT - -public: - DescriptionPage(QObject* parent = 0) : QWebEnginePage(parent){} - - bool acceptNavigationRequest(const QUrl & url, QWebEnginePage::NavigationType type, bool isMainFrame) - { - if (type == QWebEnginePage::NavigationTypeLinkClicked) - { - emit linkClicked(url); - return false; - } - return true; - } - -signals: - void linkClicked(const QUrl&); - -}; - -#endif //DESCRIPTIONPAGE_H diff --git a/src/expanderwidget.cpp b/src/expanderwidget.cpp new file mode 100644 index 00000000..2f47da5b --- /dev/null +++ b/src/expanderwidget.cpp @@ -0,0 +1,54 @@ +#include "expanderwidget.h" + +ExpanderWidget::ExpanderWidget() + : m_button(nullptr), m_content(nullptr), opened_(false) +{ +} + +ExpanderWidget::ExpanderWidget(QToolButton* button, QWidget* content) + : ExpanderWidget() +{ + set(button, content); +} + +void ExpanderWidget::set(QToolButton* button, QWidget* content, bool o) +{ + m_button = button; + m_content = content; + + m_button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + QObject::connect(m_button, &QToolButton::clicked, [&]{ toggle(); }); + + toggle(o); +} + +void ExpanderWidget::toggle() +{ + if (opened()) { + toggle(false); + } + else { + toggle(true); + } +} + +void ExpanderWidget::toggle(bool b) +{ + if (b) { + m_button->setArrowType(Qt::DownArrow); + m_content->show(); + } else { + m_button->setArrowType(Qt::RightArrow); + m_content->hide(); + } + + // the state has to be remembered instead of using m_content's visibility + // because saving the state in saveConflictExpandersState() happens after the + // dialog is closed, which marks all the widgets hidden + opened_ = b; +} + +bool ExpanderWidget::opened() const +{ + return opened_; +} diff --git a/src/expanderwidget.h b/src/expanderwidget.h new file mode 100644 index 00000000..da3eb9d6 --- /dev/null +++ b/src/expanderwidget.h @@ -0,0 +1,46 @@ +#ifndef EXPANDERWIDGET_H +#define EXPANDERWIDGET_H + +#include <QToolButton> + +/* Takes a QToolButton and a widget and creates an expandable widget. +**/ +class ExpanderWidget +{ +public: + /** empty expander, use set() + **/ + ExpanderWidget(); + + /** see set() + **/ + ExpanderWidget(QToolButton* button, QWidget* content); + + /** @brief sets the button and content widgets to use + * the button will be given an arrow icon, clicking it will toggle the + * visibility of the given widget + * @param button the button that toggles the content + * @param content the widget that will be shown or hidden + * @param opened initial state, defaults to closed + **/ + void set(QToolButton* button, QWidget* content, bool opened=false); + + /** either opens or closes the expander depending on the current state + **/ + void toggle(); + + /** sets the current state of the expander + **/ + void toggle(bool b); + + /** returns whether the expander is currently opened + **/ + bool opened() const; + +private: + QToolButton* m_button; + QWidget* m_content; + bool opened_; +}; + +#endif // EXPANDERWIDGET_H diff --git a/src/filerenamer.cpp b/src/filerenamer.cpp new file mode 100644 index 00000000..c5c6782b --- /dev/null +++ b/src/filerenamer.cpp @@ -0,0 +1,189 @@ +#include "filerenamer.h" +#include <QMessageBox> +#include <QFileInfo> + +FileRenamer::FileRenamer(QWidget* parent, QFlags<RenameFlags> flags) + : m_parent(parent), m_flags(flags) +{ + // sanity check for flags + if ((m_flags & (HIDE|UNHIDE)) == 0) { + qCritical("renameFile() missing hide flag"); + // doesn't really matter, it's just for text + m_flags = HIDE; + } +} + +FileRenamer::RenameResults FileRenamer::rename(const QString& oldName, const QString& newName) +{ + qDebug().nospace() << "renaming " << oldName << " to " << newName; + + if (QFileInfo(newName).exists()) { + qDebug().nospace() << newName << " already exists"; + + // target file already exists, confirm replacement + auto answer = confirmReplace(newName); + + switch (answer) { + case DECISION_SKIP: { + // user wants to skip this file + qDebug().nospace() << "skipping " << oldName; + return RESULT_SKIP; + } + + case DECISION_REPLACE: { + qDebug().nospace() << "removing " << newName; + // user wants to replace the file, so remove it + if (!QFile(newName).remove()) { + qWarning().nospace() << "failed to remove " << newName; + // removal failed, warn the user and allow canceling + if (!removeFailed(newName)) { + qDebug().nospace() << "canceling " << oldName; + // user wants to cancel + return RESULT_CANCEL; + } + + // ignore this file and continue on + qDebug().nospace() << "skipping " << oldName; + return RESULT_SKIP; + } + + break; + } + + case DECISION_CANCEL: // fall-through + default: { + // user wants to stop + qDebug().nospace() << "canceling"; + return RESULT_CANCEL; + } + } + } + + // target either didn't exist or was removed correctly + + if (!QFile::rename(oldName, newName)) { + qWarning().nospace() << "failed to rename " << oldName << " to " << newName; + + // renaming failed, warn the user and allow canceling + if (!renameFailed(oldName, newName)) { + // user wants to cancel + qDebug().nospace() << "canceling"; + return RESULT_CANCEL; + } + + // ignore this file and continue on + qDebug().nospace() << "skipping " << oldName; + return RESULT_SKIP; + } + + // everything worked + qDebug().nospace() << "successfully renamed " << oldName << " to " << newName; + return RESULT_OK; +} + +FileRenamer::RenameDecision FileRenamer::confirmReplace(const QString& newName) +{ + if (m_flags & REPLACE_ALL) { + // user wants to silently replace all + qDebug().nospace() << "user has selected replace all"; + return DECISION_REPLACE; + } + else if (m_flags & REPLACE_NONE) { + // user wants to silently skip all + qDebug().nospace() << "user has selected replace none"; + return DECISION_SKIP; + } + + QString text; + + if (m_flags & HIDE) { + text = QObject::tr("The hidden file \"%1\" already exists. Replace it?").arg(newName); + } + else if (m_flags & UNHIDE) { + text = QObject::tr("The visible file \"%1\" already exists. Replace it?").arg(newName); + } + + auto buttons = QMessageBox::Yes | QMessageBox::No; + if (m_flags & MULTIPLE) { + // only show these buttons when there are multiple files to replace + buttons |= QMessageBox::YesToAll | QMessageBox::NoToAll | QMessageBox::Cancel; + } + + const auto answer = QMessageBox::question( + m_parent, QObject::tr("Replace file?"), text, buttons); + + switch (answer) { + case QMessageBox::Yes: + qDebug().nospace() << "user wants to replace"; + return DECISION_REPLACE; + + case QMessageBox::No: + qDebug().nospace() << "user wants to skip"; + return DECISION_SKIP; + + case QMessageBox::YesToAll: + qDebug().nospace() << "user wants to replace all"; + // remember the answer + m_flags |= REPLACE_ALL; + return DECISION_REPLACE; + + case QMessageBox::NoToAll: + qDebug().nospace() << "user wants to replace none"; + // remember the answer + m_flags |= REPLACE_NONE; + return DECISION_SKIP; + + case QMessageBox::Cancel: // fall-through + default: + qDebug().nospace() << "user wants to cancel"; + return DECISION_CANCEL; + } +} + +bool FileRenamer::removeFailed(const QString& name) +{ + QMessageBox::StandardButtons buttons = QMessageBox::Ok; + if (m_flags & MULTIPLE) { + // only show cancel for multiple files + buttons |= QMessageBox::Cancel; + } + + const auto answer = QMessageBox::critical( + m_parent, QObject::tr("File operation failed"), + QObject::tr("Failed to remove \"%1\". Maybe you lack the required file permissions?").arg(name), + buttons); + + if (answer == QMessageBox::Cancel) { + // user wants to stop + qDebug().nospace() << "user wants to cancel"; + return false; + } + + // skip this one and continue + qDebug().nospace() << "user wants to skip"; + return true; +} + +bool FileRenamer::renameFailed(const QString& oldName, const QString& newName) +{ + QMessageBox::StandardButtons buttons = QMessageBox::Ok; + if (m_flags & MULTIPLE) { + // only show cancel for multiple files + buttons |= QMessageBox::Cancel; + } + + const auto answer = QMessageBox::critical( + m_parent, QObject::tr("File operation failed"), + QObject::tr("failed to rename %1 to %2").arg(oldName).arg(QDir::toNativeSeparators(newName)), + buttons); + + if (answer == QMessageBox::Cancel) { + // user wants to stop + qDebug().nospace() << "user wants to cancel"; + return false; + } + + // skip this one and continue + qDebug().nospace() << "user wants to skip"; + return true; +} diff --git a/src/filerenamer.h b/src/filerenamer.h new file mode 100644 index 00000000..cd57244c --- /dev/null +++ b/src/filerenamer.h @@ -0,0 +1,140 @@ +#ifndef FILERENAMER_H +#define FILERENAMER_H + +#include <QWidget> + +/** +* Renames individual files and handles dialog boxes to confirm replacements and +* failures with the user +**/ +class FileRenamer +{ +public: + /** + * controls appearance and replacement behaviour; if RENAME_REPLACE_ALL and + * RENAME_REPLACE_NONE are not provided, the user will have the option to + * choose on the first replacement + **/ + enum RenameFlags + { + /** + * this renamer will be used on multiple files, so display additional + * buttons to replace all and for canceling + **/ + MULTIPLE = 0x01, + + /** + * customizes some of the text shown on dialog to mention that files are + * being hidden + **/ + HIDE = 0x02, + + /** + * customizes some of the text shown on dialog to mention that files are + * being unhidden + **/ + UNHIDE = 0x04, + + /** + * silently replaces all existing files + **/ + REPLACE_ALL = 0x08, + + /** + * silently skips all existing files + **/ + REPLACE_NONE = 0x10, + }; + + + /** result of a single rename + * + **/ + enum RenameResults + { + /** + * the user skipped this file + */ + RESULT_SKIP, + + /** + * the file was successfully renamed + */ + RESULT_OK, + + /** + * the user wants to cancel + */ + RESULT_CANCEL + }; + + + /** + * @param parent Parent widget for dialog boxes + **/ + FileRenamer(QWidget* parent, QFlags<RenameFlags> flags); + + /** + * renames the given file + * @param oldName current filename + * @param newName new filename + * @return whether the file was renamed, skipped or the user wants to cancel + **/ + RenameResults rename(const QString& oldName, const QString& newName); + +private: + /** + *user's decision when replacing + **/ + enum RenameDecision + { + /** + * replace the file + **/ + DECISION_REPLACE, + + /** + * skip the file + **/ + DECISION_SKIP, + + /** + * cancel the whole thing + **/ + DECISION_CANCEL + }; + + /** + * parent widget for dialog boxes + **/ + QWidget* m_parent; + + /** + * flags + **/ + QFlags<RenameFlags> m_flags; + + /** + * asks the user to replace an existing file, may return early if the user + * has already selected to replace all/none + * @return whether to replace, skip or cancel + **/ + RenameDecision confirmReplace(const QString& newName); + + /** + * removal of a file failed, ask the user to continue or cancel + * @param name The name of the file that failed to be removed + * @return true to continue, false to stop + **/ + bool removeFailed(const QString& name); + + /** + * renaming a file failed, ask the user to continue or cancel + * @param oldName current filename + * @param newName new filename + * @return true to continue, false to stop + **/ + bool renameFailed(const QString& oldName, const QString& newName); +}; + +#endif // FILERENAMER_H diff --git a/src/filterwidget.cpp b/src/filterwidget.cpp index 16a46b0e..44cbb274 100644 --- a/src/filterwidget.cpp +++ b/src/filterwidget.cpp @@ -1,12 +1,41 @@ #include "filterwidget.h" #include "eventfilter.h" -FilterWidget::FilterWidget() - : m_edit(nullptr), m_eventFilter(nullptr), m_clear(nullptr) +FilterWidgetProxyModel::FilterWidgetProxyModel(FilterWidget& fw, QWidget* parent) + : QSortFilterProxyModel(parent), m_filter(fw) { + connect(&fw, &FilterWidget::changed, [&]{ invalidateFilter(); }); } -void FilterWidget::set(QLineEdit* edit) +bool FilterWidgetProxyModel::filterAcceptsRow( + int sourceRow, const QModelIndex& sourceParent) const +{ + const auto cols = sourceModel()->columnCount(); + + const auto m = m_filter.matches([&](auto&& what) { + for (int c=0; c<cols; ++c) { + QModelIndex index = sourceModel()->index(sourceRow, c, sourceParent); + const auto text = sourceModel()->data(index, Qt::DisplayRole).toString(); + + if (text.contains(what, Qt::CaseInsensitive)) { + return true; + } + } + + return false; + }); + + return m; +} + + +FilterWidget::FilterWidget() : + m_edit(nullptr), m_list(nullptr), m_proxy(nullptr), + m_eventFilter(nullptr), m_clear(nullptr) +{ +} + +void FilterWidget::setEdit(QLineEdit* edit) { unhook(); @@ -16,11 +45,22 @@ void FilterWidget::set(QLineEdit* edit) return; } + m_edit->setPlaceholderText(QObject::tr("Filter")); + createClear(); hookEvents(); clear(); } +void FilterWidget::setList(QAbstractItemView* list) +{ + m_list = list; + + m_proxy = new FilterWidgetProxyModel(*this); + m_proxy->setSourceModel(m_list->model()); + m_list->setModel(m_proxy); +} + void FilterWidget::clear() { if (!m_edit) { @@ -30,21 +70,44 @@ void FilterWidget::clear() m_edit->clear(); } -bool FilterWidget::matches(std::function<bool (const QString& what)> pred) const +bool FilterWidget::empty() const { + return m_text.isEmpty(); +} + +QModelIndex FilterWidget::map(const QModelIndex& index) +{ + if (m_proxy) { + return m_proxy->mapToSource(index); + } else { + qCritical() << "FilterWidget::map() called, but proxy isn't set up"; + return index; + } +} + +void FilterWidget::compile() +{ + m_compiled.clear(); + const QStringList ORList = [&] { QString filterCopy = QString(m_text); filterCopy.replace("||", ";").replace("OR", ";").replace("|", ";"); return filterCopy.split(";", QString::SkipEmptyParts); }(); - if (ORList.isEmpty() || !pred) { + // split in ORSegments that internally use AND logic + for (auto& ORSegment : ORList) { + m_compiled.push_back(ORSegment.split(" ", QString::SkipEmptyParts)); + } +} + +bool FilterWidget::matches(std::function<bool (const QString& what)> pred) const +{ + if (m_compiled.isEmpty() || !pred) { return true; } - // split in ORSegments that internally use AND logic - for (auto& ORSegment : ORList) { - QStringList ANDKeywords = ORSegment.split(" ", QString::SkipEmptyParts); + for (auto& ANDKeywords : m_compiled) { bool segmentGood = true; // check each word in the segment for match, each word needs to be matched @@ -75,6 +138,14 @@ void FilterWidget::unhook() if (m_edit) { m_edit->removeEventFilter(m_eventFilter); } + + if (m_proxy && m_list) { + auto* model = m_proxy->sourceModel(); + m_proxy->setSourceModel(nullptr); + delete m_proxy; + + m_list->setModel(model); + } } void FilterWidget::createClear() @@ -115,10 +186,13 @@ void FilterWidget::onTextChanged() if (text != m_text) { m_text = text; + compile(); - if (changed) { - changed(); + if (m_proxy) { + m_proxy->invalidateFilter(); } + + emit changed(); } } diff --git a/src/filterwidget.h b/src/filterwidget.h index 4fee5983..4fb9831f 100644 --- a/src/filterwidget.h +++ b/src/filterwidget.h @@ -1,25 +1,60 @@ #ifndef FILTERWIDGET_H #define FILTERWIDGET_H +#include <QObject> +#include <QLineEdit> +#include <QToolButton> +#include <QList> +#include <QAbstractItemView> + class EventFilter; +class FilterWidget; + +class FilterWidgetProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT; + +public: + FilterWidgetProxyModel(FilterWidget& fw, QWidget* parent=nullptr); + using QSortFilterProxyModel::invalidateFilter; + +protected: + bool filterAcceptsRow(int row, const QModelIndex& parent) const override; + +private: + FilterWidget& m_filter; +}; -class FilterWidget + +class FilterWidget : public QObject { + Q_OBJECT; + public: - std::function<void ()> changed; + using predFun = std::function<bool (const QString& what)>; FilterWidget(); - void set(QLineEdit* edit); + void setEdit(QLineEdit* edit); + void setList(QAbstractItemView* list); void clear(); + bool empty() const; - bool matches(std::function<bool (const QString& what)> pred) const; + QModelIndex map(const QModelIndex& index); + + bool matches(predFun pred) const; + +signals: + void changed(); private: QLineEdit* m_edit; + QAbstractItemView* m_list; + FilterWidgetProxyModel* m_proxy; EventFilter* m_eventFilter; QToolButton* m_clear; QString m_text; + QList<QList<QString>> m_compiled; void unhook(); void createClear(); @@ -28,6 +63,8 @@ private: void onTextChanged(); void onResized(); + + void compile(); }; #endif // FILTERWIDGET_H diff --git a/src/iuserinterface.h b/src/iuserinterface.h index 034fa029..bba8de2b 100644 --- a/src/iuserinterface.h +++ b/src/iuserinterface.h @@ -2,7 +2,7 @@ #define IUSERINTERFACE_H
-#include "modinfo.h"
+#include "modinfodialogfwd.h"
#include "ilockedwaitingforprocess.h"
#include <iplugintool.h>
#include <ipluginmodpage.h>
@@ -29,7 +29,8 @@ public: virtual bool closeWindow() = 0;
virtual void setWindowEnabled(bool enabled) = 0;
- virtual void displayModInformation(ModInfo::Ptr modInfo, unsigned int index, int tab) = 0;
+ virtual void displayModInformation(
+ ModInfoPtr modInfo, unsigned int modIndex, ModInfoTabIDs tabID) = 0;
virtual void updateBSAList(const QStringList &defaultArchives, const QStringList &activeArchives) = 0;
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index da7c721d..7681b482 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -3073,66 +3073,34 @@ void MainWindow::backupMod_clicked() void MainWindow::resumeDownload(int downloadIndex) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { + m_OrganizerCore.loggedInAction(this, [this, downloadIndex] { m_OrganizerCore.downloadManager()->resumeDownload(downloadIndex); - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - m_OrganizerCore.doAfterLogin([this, downloadIndex] () { - this->resumeDownload(downloadIndex); - }); - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to resume a download"), this); - } - } + }); } void MainWindow::endorseMod(ModInfo::Ptr mod) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { + m_OrganizerCore.loggedInAction(this, [this, mod] { mod->endorse(true); - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - m_OrganizerCore.doAfterLogin([&]() { this->endorseMod(mod); }); - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to endorse"), this); - } - } + }); } void MainWindow::endorse_clicked() { QItemSelectionModel *selection = ui->modList->selectionModel(); - if (selection->hasSelection() && selection->selectedRows().count() > 1) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { + + m_OrganizerCore.loggedInAction(this, [this] { + QItemSelectionModel *selection = ui->modList->selectionModel(); + if (selection->hasSelection() && selection->selectedRows().count() > 1) { MessageDialog::showMessage(tr("Endorsing multiple mods will take a while. Please wait..."), this); - for (QModelIndex idx : selection->selectedRows()) { - ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->endorse(true); - } } - else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - MessageDialog::showMessage(tr("Endorsing multiple mods will take a while. Please wait..."), this); - for (QModelIndex idx : selection->selectedRows()) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt()); - m_OrganizerCore.doAfterLogin([&]() { this->endorseMod(modInfo); }); - } - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to endorse"), this); - return; - } + + for (QModelIndex idx : selection->selectedRows()) { + ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->endorse(true); } - } - else { - endorseMod(ModInfo::getByIndex(m_ContextRow)); - } + }); } void MainWindow::dontendorse_clicked() @@ -3151,114 +3119,53 @@ void MainWindow::dontendorse_clicked() void MainWindow::unendorseMod(ModInfo::Ptr mod) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { - ModInfo::getByIndex(m_ContextRow)->endorse(false); - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - m_OrganizerCore.doAfterLogin([&]() { this->unendorseMod(mod); }); - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to endorse"), this); - } - } + m_OrganizerCore.loggedInAction(this, [mod] { + mod->endorse(false); + }); } void MainWindow::unendorse_clicked() { - QItemSelectionModel *selection = ui->modList->selectionModel(); - if (selection->hasSelection() && selection->selectedRows().count() > 1) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { + m_OrganizerCore.loggedInAction(this, [this] { + QItemSelectionModel *selection = ui->modList->selectionModel(); + if (selection->hasSelection() && selection->selectedRows().count() > 1) { MessageDialog::showMessage(tr("Unendorsing multiple mods will take a while. Please wait..."), this); - for (QModelIndex idx : selection->selectedRows()) { - ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->endorse(false); - } - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - MessageDialog::showMessage(tr("Unendorsing multiple mods will take a while. Please wait..."), this); - for (QModelIndex idx : selection->selectedRows()) { - ModInfo::Ptr modInfo = ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt()); - m_OrganizerCore.doAfterLogin([&]() { this->unendorseMod(modInfo); }); - } - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to endorse"), this); - return; - } } - } - else { - unendorseMod(ModInfo::getByIndex(m_ContextRow)); - } + + for (QModelIndex idx : selection->selectedRows()) { + ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->endorse(false); + } + }); } void MainWindow::trackMod(ModInfo::Ptr mod, bool doTrack) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { - ModInfo::getByIndex(m_ContextRow)->track(doTrack); - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - m_OrganizerCore.doAfterLogin([&]() { this->trackMod(mod, doTrack); }); - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to track"), this); - } - } + m_OrganizerCore.loggedInAction(this, [mod, doTrack] { + mod->track(doTrack); + }); } void MainWindow::track_clicked() { - QItemSelectionModel *selection = ui->modList->selectionModel(); - if (selection->hasSelection() && selection->selectedRows().count() > 1) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { - for (auto idx : selection->selectedRows()) { - ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->track(true); - } - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - for (auto idx : selection->selectedRows()) { - auto modInfo = ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt()); - m_OrganizerCore.doAfterLogin([&]() { this->trackMod(modInfo, true); }); - } - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to track"), this); - } + m_OrganizerCore.loggedInAction(this, [this] { + QItemSelectionModel *selection = ui->modList->selectionModel(); + for (auto idx : selection->selectedRows()) { + ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->track(true); } - } else { - trackMod(ModInfo::getByIndex(m_ContextRow), true); - } + }); } void MainWindow::untrack_clicked() { - QItemSelectionModel *selection = ui->modList->selectionModel(); - if (selection->hasSelection() && selection->selectedRows().count() > 1) { - if (NexusInterface::instance(&m_PluginContainer)->getAccessManager()->validated()) { - for (auto idx : selection->selectedRows()) { - ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->track(false); - } - } else { - QString apiKey; - if (m_OrganizerCore.settings().getNexusApiKey(apiKey)) { - for (auto idx : selection->selectedRows()) { - auto modInfo = ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt()); - m_OrganizerCore.doAfterLogin([&]() { this->trackMod(modInfo, false); }); - } - NexusInterface::instance(&m_PluginContainer)->getAccessManager()->apiCheck(apiKey); - } else { - MessageDialog::showMessage(tr("You need to be logged in with Nexus to track"), this); - } + m_OrganizerCore.loggedInAction(this, [this] { + QItemSelectionModel *selection = ui->modList->selectionModel(); + for (auto idx : selection->selectedRows()) { + ModInfo::getByIndex(idx.data(Qt::UserRole + 1).toInt())->track(false); } - } else { - trackMod(ModInfo::getByIndex(m_ContextRow), false); - } + }); } void MainWindow::validationFailed(const QString &error) @@ -3285,7 +3192,8 @@ void MainWindow::overwriteClosed(int) } -void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int index, int tab) +void MainWindow::displayModInformation( + ModInfo::Ptr modInfo, unsigned int modIndex, ModInfoTabIDs tabID) { if (!m_OrganizerCore.modList()->modInfoAboutToChange(modInfo)) { qDebug("A different mod information dialog is open. If this is incorrect, please restart MO"); @@ -3315,36 +3223,21 @@ void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int index, } } else { modInfo->saveMeta(); - ModInfoDialog dialog(modInfo, m_OrganizerCore.directoryStructure(), modInfo->hasFlag(ModInfo::FLAG_FOREIGN), &m_OrganizerCore, &m_PluginContainer, this); - connect(&dialog, SIGNAL(linkActivated(QString)), this, SLOT(linkClicked(QString))); - connect(&dialog, SIGNAL(downloadRequest(QString)), &m_OrganizerCore, SLOT(downloadRequestedNXM(QString))); - connect(&dialog, SIGNAL(modOpen(QString, int)), this, SLOT(displayModInformation(QString, int)), Qt::QueuedConnection); - connect(&dialog, SIGNAL(modOpenNext(int)), this, SLOT(modOpenNext(int)), Qt::QueuedConnection); - connect(&dialog, SIGNAL(modOpenPrev(int)), this, SLOT(modOpenPrev(int)), Qt::QueuedConnection); + + ModInfoDialog dialog(this, &m_OrganizerCore, &m_PluginContainer, modInfo); connect(&dialog, SIGNAL(originModified(int)), this, SLOT(originModified(int))); - connect(&dialog, SIGNAL(endorseMod(ModInfo::Ptr)), this, SLOT(endorseMod(ModInfo::Ptr))); - //Open the tab first if we want to use the standard indexes of the tabs. - if (tab != -1) { - dialog.openTab(tab); - } + //Open the tab first if we want to use the standard indexes of the tabs. + if (tabID != ModInfoTabIDs::None) { + dialog.selectTab(tabID); + } - dialog.restoreState(m_OrganizerCore.settings()); - QSettings &settings = m_OrganizerCore.settings().directInterface(); - QString key = QString("geometry/%1").arg(dialog.objectName()); - if (settings.contains(key)) { - dialog.restoreGeometry(settings.value(key).toByteArray()); - } - - //If no tab was specified use the first tab from the left based on the user order. - if (tab == -1) { - for (int i = 0; i < dialog.findChild<QTabWidget*>("tabWidget")->count(); ++i) { - if (dialog.findChild<QTabWidget*>("tabWidget")->isTabEnabled(i)) { - dialog.findChild<QTabWidget*>("tabWidget")->setCurrentIndex(i); - break; - } - } - } + dialog.restoreState(m_OrganizerCore.settings()); + QSettings &settings = m_OrganizerCore.settings().directInterface(); + QString key = QString("geometry/%1").arg(dialog.objectName()); + if (settings.contains(key)) { + dialog.restoreGeometry(settings.value(key).toByteArray()); + } dialog.exec(); dialog.saveState(m_OrganizerCore.settings()); @@ -3355,7 +3248,7 @@ void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int index, m_OrganizerCore.modList()->modInfoChanged(modInfo); } - if (m_OrganizerCore.currentProfile()->modEnabled(index) + if (m_OrganizerCore.currentProfile()->modEnabled(modIndex) && !modInfo->hasFlag(ModInfo::FLAG_FOREIGN)) { FilesOrigin& origin = m_OrganizerCore.directoryStructure()->getOriginByName(ToWString(modInfo->name())); origin.enable(false); @@ -3366,7 +3259,7 @@ void MainWindow::displayModInformation(ModInfo::Ptr modInfo, unsigned int index, m_OrganizerCore.directoryRefresher()->addModToStructure(m_OrganizerCore.directoryStructure() , modInfo->name() - , m_OrganizerCore.currentProfile()->getModPriority(index) + , m_OrganizerCore.currentProfile()->getModPriority(modIndex) , modInfo->absolutePath() , modInfo->stealFiles() , modInfo->archives()); @@ -3388,46 +3281,74 @@ void MainWindow::setWindowEnabled(bool enabled) } -void MainWindow::modOpenNext(int tab) +ModInfo::Ptr MainWindow::nextModInList() { - QModelIndex index = m_ModListSortProxy->mapFromSource(m_OrganizerCore.modList()->index(m_ContextRow, 0)); - index = m_ModListSortProxy->index((index.row() + 1) % m_ModListSortProxy->rowCount(), 0); + const QModelIndex start = m_ModListSortProxy->mapFromSource( + m_OrganizerCore.modList()->index(m_ContextRow, 0)); + + auto index = start; + + for (;;) { + index = m_ModListSortProxy->index((index.row() + 1) % m_ModListSortProxy->rowCount(), 0); + m_ContextRow = m_ModListSortProxy->mapToSource(index).row(); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } + + ModInfo::Ptr mod = ModInfo::getByIndex(m_ContextRow); - m_ContextRow = m_ModListSortProxy->mapToSource(index).row(); - ModInfo::Ptr mod = ModInfo::getByIndex(m_ContextRow); - std::vector<ModInfo::EFlag> flags = mod->getFlags(); - if ((std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) || - (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) != flags.end()) || - (std::find(flags.begin(), flags.end(), ModInfo::FLAG_SEPARATOR) != flags.end())) { // skip overwrite and backups and separators - modOpenNext(tab); - } else { - displayModInformation(m_ContextRow,tab); + if (mod->hasFlag(ModInfo::FLAG_OVERWRITE) || + mod->hasFlag(ModInfo::FLAG_BACKUP) || + mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { + continue; + } + + return mod; } + + return {}; } -void MainWindow::modOpenPrev(int tab) +ModInfo::Ptr MainWindow::previousModInList() { - QModelIndex index = m_ModListSortProxy->mapFromSource(m_OrganizerCore.modList()->index(m_ContextRow, 0)); - int row = index.row() - 1; - if (row == -1) { - row = m_ModListSortProxy->rowCount() - 1; - } + const QModelIndex start = m_ModListSortProxy->mapFromSource( + m_OrganizerCore.modList()->index(m_ContextRow, 0)); + + auto index = start; + + for (;;) { + int row = index.row() - 1; + if (row == -1) { + row = m_ModListSortProxy->rowCount() - 1; + } + + index = m_ModListSortProxy->index(row, 0); + m_ContextRow = m_ModListSortProxy->mapToSource(index).row(); + + if (index == start || !index.isValid()) { + // wrapped around, give up + break; + } - m_ContextRow = m_ModListSortProxy->mapToSource(m_ModListSortProxy->index(row, 0)).row(); - ModInfo::Ptr mod = ModInfo::getByIndex(m_ContextRow); - std::vector<ModInfo::EFlag> flags = mod->getFlags(); - if ((std::find(flags.begin(), flags.end(), ModInfo::FLAG_OVERWRITE) != flags.end()) || - (std::find(flags.begin(), flags.end(), ModInfo::FLAG_BACKUP) != flags.end()) || - (std::find(flags.begin(), flags.end(), ModInfo::FLAG_SEPARATOR) != flags.end())) { // skip overwrite and backups and separators - modOpenPrev(tab); - } else { - displayModInformation(m_ContextRow,tab); + ModInfo::Ptr mod = ModInfo::getByIndex(m_ContextRow); + + if (mod->hasFlag(ModInfo::FLAG_OVERWRITE) || + mod->hasFlag(ModInfo::FLAG_BACKUP) || + mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { + continue; + } + + return mod; } + + return {}; } -void MainWindow::displayModInformation(const QString &modName, int tab) +void MainWindow::displayModInformation(const QString &modName, ModInfoTabIDs tabID) { unsigned int index = ModInfo::getIndex(modName); if (index == UINT_MAX) { @@ -3436,14 +3357,14 @@ void MainWindow::displayModInformation(const QString &modName, int tab) } ModInfo::Ptr modInfo = ModInfo::getByIndex(index); - displayModInformation(modInfo, index, tab); + displayModInformation(modInfo, index, tabID); } -void MainWindow::displayModInformation(int row, int tab) +void MainWindow::displayModInformation(int row, ModInfoTabIDs tabID) { ModInfo::Ptr modInfo = ModInfo::getByIndex(row); - displayModInformation(modInfo, row, tab); + displayModInformation(modInfo, row, tabID); } @@ -3507,18 +3428,16 @@ void MainWindow::visitOnNexus_clicked() int row_idx; ModInfo::Ptr info; QString gameName; - QString webUrl; + for (QModelIndex idx : selection->selectedRows()) { row_idx = idx.data(Qt::UserRole + 1).toInt(); info = ModInfo::getByIndex(row_idx); int modID = info->getNexusID(); - webUrl = info->getURL(); gameName = info->getGameName(); if (modID > 0) { linkClicked(NexusInterface::instance(&m_PluginContainer)->getModURL(modID, gameName)); - } - else if (webUrl != "") { - linkClicked(webUrl); + } else { + qCritical() << "mod '" << info->name() << "' has no nexus id"; } } } @@ -3528,14 +3447,13 @@ void MainWindow::visitOnNexus_clicked() if (modID > 0) { linkClicked(NexusInterface::instance(&m_PluginContainer)->getModURL(modID, gameName)); } else { - MessageDialog::showMessage(tr("Nexus ID for this Mod is unknown"), this); + MessageDialog::showMessage(tr("Nexus ID for this mod is unknown"), this); } } } void MainWindow::visitWebPage_clicked() { - QItemSelectionModel *selection = ui->modList->selectionModel(); if (selection->hasSelection() && selection->selectedRows().count() > 1) { int count = selection->selectedRows().count(); @@ -3549,28 +3467,22 @@ void MainWindow::visitWebPage_clicked() int row_idx; ModInfo::Ptr info; QString gameName; - QString webUrl; for (QModelIndex idx : selection->selectedRows()) { row_idx = idx.data(Qt::UserRole + 1).toInt(); info = ModInfo::getByIndex(row_idx); - int modID = info->getNexusID(); - webUrl = info->getURL(); - gameName = info->getGameName(); - if (modID > 0) { - linkClicked(NexusInterface::instance(&m_PluginContainer)->getModURL(modID, gameName)); - } - else if (webUrl != "") { - linkClicked(webUrl); + + const auto url = info->parseCustomURL(); + if (url.isValid()) { + linkClicked(url.toString()); } } } else { ModInfo::Ptr info = ModInfo::getByIndex(m_ContextRow); - if (info->getURL() != "") { - linkClicked(info->getURL()); - } - else { - MessageDialog::showMessage(tr("Web page for this mod is unknown"), this); + + const auto url = info->parseCustomURL(); + if (url.isValid()) { + linkClicked(url.toString()); } } } @@ -4137,16 +4049,18 @@ void MainWindow::on_modList_doubleClicked(const QModelIndex &index) try { m_ContextRow = m_ModListSortProxy->mapToSource(index).row(); sourceIdx.column(); - int tab = -1; + + auto tab = ModInfoTabIDs::None; + switch (sourceIdx.column()) { - case ModList::COL_NOTES: tab = ModInfoDialog::TAB_NOTES; break; - case ModList::COL_VERSION: tab = ModInfoDialog::TAB_NEXUS; break; - case ModList::COL_MODID: tab = ModInfoDialog::TAB_NEXUS; break; - case ModList::COL_GAME: tab = ModInfoDialog::TAB_NEXUS; break; - case ModList::COL_CATEGORY: tab = ModInfoDialog::TAB_CATEGORIES; break; - case ModList::COL_FLAGS: tab = ModInfoDialog::TAB_CONFLICTS; break; - default: tab = -1; + case ModList::COL_NOTES: tab = ModInfoTabIDs::Notes; break; + case ModList::COL_VERSION: tab = ModInfoTabIDs::Nexus; break; + case ModList::COL_MODID: tab = ModInfoTabIDs::Nexus; break; + case ModList::COL_GAME: tab = ModInfoTabIDs::Nexus; break; + case ModList::COL_CATEGORY: tab = ModInfoTabIDs::Categories; break; + case ModList::COL_FLAGS: tab = ModInfoTabIDs::Conflicts; break; } + displayModInformation(sourceIdx.row(), tab); // workaround to cancel the editor that might have opened because of // selection-click @@ -4789,7 +4703,7 @@ void MainWindow::exportModListCSV() builder.writeHeader(); auto indexesByPriority = m_OrganizerCore.currentProfile()->getAllIndexesByPriority(); - for (auto& iter : indexesByPriority) { + for (auto& iter : indexesByPriority) { ModInfo::Ptr info = ModInfo::getByIndex(iter.second); bool enabled = m_OrganizerCore.currentProfile()->modEnabled(iter.second); if ((selectedRowID == 1) && !enabled) { @@ -5069,8 +4983,13 @@ void MainWindow::on_modList_customContextMenuRequested(const QPoint &pos) if (info->getNexusID() > 0) { menu.addAction(tr("Visit on Nexus"), this, SLOT(visitOnNexus_clicked())); - } else if ((info->getURL() != "")) { - menu.addAction(tr("Visit web page"), this, SLOT(visitWebPage_clicked())); + } + + const auto url = info->parseCustomURL(); + if (url.isValid()) { + menu.addAction( + tr("Visit on %1").arg(url.host()), + this, SLOT(visitWebPage_clicked())); } menu.addAction(tr("Open in Explorer"), this, SLOT(openExplorer_clicked())); diff --git a/src/mainwindow.h b/src/mainwindow.h index b6283a26..00f15a2b 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -151,7 +151,8 @@ public: virtual void disconnectPlugins(); - void displayModInformation(ModInfo::Ptr modInfo, unsigned int index, int tab); + void displayModInformation( + ModInfo::Ptr modInfo, unsigned int modIndex, ModInfoTabIDs tabID) override; bool confirmExit(); @@ -160,6 +161,9 @@ public: virtual MOBase::DelayedFileWriterBase &archivesWriter() override { return m_ArchiveListWriter; } + ModInfo::Ptr nextModInList(); + ModInfo::Ptr previousModInList(); + public slots: void displayColumnSelection(const QPoint &pos); @@ -232,7 +236,7 @@ private: QList<MOBase::IOrganizer::FileInfo> findFileInfos(const QString &path, const std::function<bool (const MOBase::IOrganizer::FileInfo &)> &filter) const; bool modifyExecutablesDialog(); - void displayModInformation(int row, int tab = -1); + void displayModInformation(int row, ModInfoTabIDs tab=ModInfoTabIDs::None); void testExtractBSA(int modIndex); void writeDataToFile(QFile &file, const QString &directory, const MOShared::DirectoryEntry &directoryEntry); @@ -548,9 +552,7 @@ private slots: void editCategories(); void deselectFilters(); - void displayModInformation(const QString &modName, int tab); - void modOpenNext(int tab=-1); - void modOpenPrev(int tab=-1); + void displayModInformation(const QString &modName, ModInfoTabIDs tabID); void modRenamed(const QString &oldName, const QString &newName); void modRemoved(const QString &fileName); diff --git a/src/modinfo.cpp b/src/modinfo.cpp index bc2979ef..585d4963 100644 --- a/src/modinfo.cpp +++ b/src/modinfo.cpp @@ -520,3 +520,22 @@ void ModInfo::testValid() dirIter.next(); } } + +QUrl ModInfo::parseCustomURL() const +{ + if (!hasCustomURL() || getCustomURL().isEmpty()) { + return {}; + } + + const auto url = QUrl::fromUserInput(getCustomURL()); + + if (!url.isValid()) { + qCritical() + << "mod '" << name() << "' has an invalid custom url " + << "'" << getCustomURL() << "'"; + + return {}; + } + + return url; +} diff --git a/src/modinfo.h b/src/modinfo.h index f1d816fe..e395f45b 100644 --- a/src/modinfo.h +++ b/src/modinfo.h @@ -733,14 +733,31 @@ public: virtual void doConflictCheck() const {} /** - * @brief set the URL for a mod - */ - virtual void setURL(QString const &) {} + * @brief sets whether this mod uses a custom url + **/ + virtual void setHasCustomURL(bool) {} /** - * @returns the URL for a mod - */ - virtual QString getURL() const { return ""; } + * @brief returns whether this mod uses a custom url + **/ + virtual bool hasCustomURL() const { return false; } + + /** + * @brief sets the custom url + **/ + virtual void setCustomURL(QString const &) {} + + /** + * @brief returns the custom url + **/ + virtual QString getCustomURL() const { return ""; } + + /** + * If hasCustomURL() is true and getCustomURL() is not empty, tries to parse + * the url using QUrl::fromUserInput() and returns it. Otherwise, returns an + * empty QUrl. + **/ + QUrl parseCustomURL() const; signals: diff --git a/src/modinfodialog.cpp b/src/modinfodialog.cpp index 188ea956..16690019 100644 --- a/src/modinfodialog.cpp +++ b/src/modinfodialog.cpp @@ -19,329 +19,46 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "modinfodialog.h" #include "ui_modinfodialog.h" -#include "descriptionpage.h" -#include "mainwindow.h" - -#include "modidlineedit.h" -#include "iplugingame.h" -#include "nexusinterface.h" -#include "report.h" -#include "utility.h" -#include "messagedialog.h" -#include "bbcode.h" -#include "questionboxmemory.h" -#include "settings.h" -#include "categories.h" +#include "plugincontainer.h" #include "organizercore.h" -#include "pluginlistsortproxy.h" -#include "previewgenerator.h" -#include "previewdialog.h" - -#include <QDir> -#include <QDirIterator> -#include <QPushButton> -#include <QInputDialog> -#include <QMessageBox> -#include <QMenu> -#include <QFileSystemModel> -#include <QInputDialog> -#include <QPointer> -#include <QFileDialog> -#include <QShortcut> - -#include <Shlwapi.h> - -#include <sstream> - +#include "mainwindow.h" +#include "modinfodialogtextfiles.h" +#include "modinfodialogimages.h" +#include "modinfodialogesps.h" +#include "modinfodialogconflicts.h" +#include "modinfodialogcategories.h" +#include "modinfodialognexus.h" +#include "modinfodialogfiletree.h" +#include <filesystem> using namespace MOBase; using namespace MOShared; +namespace fs = std::filesystem; - -class ModFileListWidget : public QListWidgetItem { - friend bool operator<(const ModFileListWidget &LHS, const ModFileListWidget &RHS); -public: - ModFileListWidget(const QString &text, int sortValue, QListWidget *parent = 0) - : QListWidgetItem(text, parent, QListWidgetItem::UserType + 1), m_SortValue(sortValue) {} -private: - int m_SortValue; -}; - - -static bool operator<(const ModFileListWidget &LHS, const ModFileListWidget &RHS) -{ - return LHS.m_SortValue < RHS.m_SortValue; -} - -// if there are more than 50 selected items in the conflict tree or filetree, -// don't bother checking whether menu items apply to them, just show all of them const int max_scan_for_context_menu = 50; -FileRenamer::FileRenamer(QWidget* parent, QFlags<RenameFlags> flags) - : m_parent(parent), m_flags(flags) -{ - // sanity check for flags - if ((m_flags & (HIDE|UNHIDE)) == 0) { - qCritical("renameFile() missing hide flag"); - // doesn't really matter, it's just for text - m_flags = HIDE; - } -} - -FileRenamer::RenameResults FileRenamer::rename(const QString& oldName, const QString& newName) -{ - qDebug().nospace() << "renaming " << oldName << " to " << newName; - - if (QFileInfo(newName).exists()) { - qDebug().nospace() << newName << " already exists"; - - // target file already exists, confirm replacement - auto answer = confirmReplace(newName); - - switch (answer) { - case DECISION_SKIP: { - // user wants to skip this file - qDebug().nospace() << "skipping " << oldName; - return RESULT_SKIP; - } - - case DECISION_REPLACE: { - qDebug().nospace() << "removing " << newName; - // user wants to replace the file, so remove it - if (!QFile(newName).remove()) { - qWarning().nospace() << "failed to remove " << newName; - // removal failed, warn the user and allow canceling - if (!removeFailed(newName)) { - qDebug().nospace() << "canceling " << oldName; - // user wants to cancel - return RESULT_CANCEL; - } - - // ignore this file and continue on - qDebug().nospace() << "skipping " << oldName; - return RESULT_SKIP; - } - - break; - } - - case DECISION_CANCEL: // fall-through - default: { - // user wants to stop - qDebug().nospace() << "canceling"; - return RESULT_CANCEL; - } - } - } - - // target either didn't exist or was removed correctly - - if (!QFile::rename(oldName, newName)) { - qWarning().nospace() << "failed to rename " << oldName << " to " << newName; - - // renaming failed, warn the user and allow canceling - if (!renameFailed(oldName, newName)) { - // user wants to cancel - qDebug().nospace() << "canceling"; - return RESULT_CANCEL; - } - - // ignore this file and continue on - qDebug().nospace() << "skipping " << oldName; - return RESULT_SKIP; - } - - // everything worked - qDebug().nospace() << "successfully renamed " << oldName << " to " << newName; - return RESULT_OK; -} - -FileRenamer::RenameDecision FileRenamer::confirmReplace(const QString& newName) -{ - if (m_flags & REPLACE_ALL) { - // user wants to silently replace all - qDebug().nospace() << "user has selected replace all"; - return DECISION_REPLACE; - } - else if (m_flags & REPLACE_NONE) { - // user wants to silently skip all - qDebug().nospace() << "user has selected replace none"; - return DECISION_SKIP; - } - - QString text; - - if (m_flags & HIDE) { - text = QObject::tr("The hidden file \"%1\" already exists. Replace it?").arg(newName); - } - else if (m_flags & UNHIDE) { - text = QObject::tr("The visible file \"%1\" already exists. Replace it?").arg(newName); - } - - auto buttons = QMessageBox::Yes | QMessageBox::No; - if (m_flags & MULTIPLE) { - // only show these buttons when there are multiple files to replace - buttons |= QMessageBox::YesToAll | QMessageBox::NoToAll | QMessageBox::Cancel; - } - - const auto answer = QMessageBox::question( - m_parent, QObject::tr("Replace file?"), text, buttons); - - switch (answer) { - case QMessageBox::Yes: - qDebug().nospace() << "user wants to replace"; - return DECISION_REPLACE; - - case QMessageBox::No: - qDebug().nospace() << "user wants to skip"; - return DECISION_SKIP; - - case QMessageBox::YesToAll: - qDebug().nospace() << "user wants to replace all"; - // remember the answer - m_flags |= REPLACE_ALL; - return DECISION_REPLACE; - - case QMessageBox::NoToAll: - qDebug().nospace() << "user wants to replace none"; - // remember the answer - m_flags |= REPLACE_NONE; - return DECISION_SKIP; - - case QMessageBox::Cancel: // fall-through - default: - qDebug().nospace() << "user wants to cancel"; - return DECISION_CANCEL; - } -} - -bool FileRenamer::removeFailed(const QString& name) -{ - QMessageBox::StandardButtons buttons = QMessageBox::Ok; - if (m_flags & MULTIPLE) { - // only show cancel for multiple files - buttons |= QMessageBox::Cancel; - } - - const auto answer = QMessageBox::critical( - m_parent, QObject::tr("File operation failed"), - QObject::tr("Failed to remove \"%1\". Maybe you lack the required file permissions?").arg(name), - buttons); - - if (answer == QMessageBox::Cancel) { - // user wants to stop - qDebug().nospace() << "user wants to cancel"; - return false; - } - - // skip this one and continue - qDebug().nospace() << "user wants to skip"; - return true; -} - -bool FileRenamer::renameFailed(const QString& oldName, const QString& newName) -{ - QMessageBox::StandardButtons buttons = QMessageBox::Ok; - if (m_flags & MULTIPLE) { - // only show cancel for multiple files - buttons |= QMessageBox::Cancel; - } - - const auto answer = QMessageBox::critical( - m_parent, QObject::tr("File operation failed"), - QObject::tr("failed to rename %1 to %2").arg(oldName).arg(QDir::toNativeSeparators(newName)), - buttons); - - if (answer == QMessageBox::Cancel) { - // user wants to stop - qDebug().nospace() << "user wants to cancel"; - return false; - } - - // skip this one and continue - qDebug().nospace() << "user wants to skip"; - return true; -} - - -ExpanderWidget::ExpanderWidget() - : m_button(nullptr), m_content(nullptr), opened_(false) -{ -} - -ExpanderWidget::ExpanderWidget(QToolButton* button, QWidget* content) - : ExpanderWidget() -{ - set(button, content); -} - -void ExpanderWidget::set(QToolButton* button, QWidget* content, bool o) -{ - m_button = button; - m_content = content; - - m_button->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - QObject::connect(m_button, &QToolButton::clicked, [&]{ toggle(); }); - - toggle(o); -} - -void ExpanderWidget::toggle() -{ - if (opened()) { - toggle(false); - } - else { - toggle(true); - } -} - -void ExpanderWidget::toggle(bool b) +int naturalCompare(const QString& a, const QString& b) { - if (b) { - m_button->setArrowType(Qt::DownArrow); - m_content->show(); - } else { - m_button->setArrowType(Qt::RightArrow); - m_content->hide(); - } - - // the state has to be remembered instead of using m_content's visibility - // because saving the state in saveConflictExpandersState() happens after the - // dialog is closed, which marks all the widgets hidden - opened_ = b; -} + static QCollator c = []{ + QCollator c; + c.setNumericMode(true); + c.setCaseSensitivity(Qt::CaseInsensitive); + return c; + }(); -bool ExpanderWidget::opened() const -{ - return opened_; + return c.compare(a, b); } - -class ElideLeftDelegate : public QStyledItemDelegate -{ -public: - using QStyledItemDelegate::QStyledItemDelegate; - -protected: - void initStyleOption(QStyleOptionViewItem *option, const QModelIndex &index) const - { - QStyledItemDelegate::initStyleOption(option, index); - option->textElideMode = Qt::ElideLeft; - } -}; - - bool canPreviewFile( - PluginContainer* pluginContainer, bool isArchive, const QString& filename) + PluginContainer& pluginContainer, bool isArchive, const QString& filename) { if (isArchive) { return false; } const auto ext = QFileInfo(filename).suffix(); - return pluginContainer->previewGenerator().previewSupported(ext); + return pluginContainer.previewGenerator().previewSupported(ext); } bool canOpenFile(bool isArchive, const QString&) @@ -350,6 +67,12 @@ bool canOpenFile(bool isArchive, const QString&) return !isArchive; } +bool canExploreFile(bool isArchive, const QString&) +{ + // can explore anything as long as it's not in an archive + return !isArchive; +} + bool canHideFile(bool isArchive, const QString& filename) { if (isArchive) { @@ -380,2050 +103,675 @@ bool canUnhideFile(bool isArchive, const QString& filename) return true; } - -int naturalCompare(const QString& a, const QString& b) +FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString &oldName) { - static QCollator c = []{ - QCollator c; - c.setNumericMode(true); - c.setCaseSensitivity(Qt::CaseInsensitive); - return c; - }(); - - return c.compare(a, b); + const QString newName = oldName + ModInfo::s_HiddenExt; + return renamer.rename(oldName, newName); } - -class ConflictItem : public QTreeWidgetItem -{ -public: - static const int FILENAME_USERROLE = Qt::UserRole + 1; - static const int ALT_ORIGIN_USERROLE = Qt::UserRole + 2; - static const int ARCHIVE_USERROLE = Qt::UserRole + 3; - static const int INDEX_USERROLE = Qt::UserRole + 4; - static const int HAS_ALTS_USERROLE = Qt::UserRole + 5; - - ConflictItem( - QStringList columns, FileEntry::Index index, const QString& fileName, - bool hasAltOrigins, const QString& altOrigin, bool archive) - : QTreeWidgetItem(columns) - { - setData(0, FILENAME_USERROLE, fileName); - setData(0, ALT_ORIGIN_USERROLE, altOrigin); - setData(0, ARCHIVE_USERROLE, archive); - setData(0, INDEX_USERROLE, index); - setData(0, HAS_ALTS_USERROLE, hasAltOrigins); - - if (archive) { - QFont f = font(0); - f.setItalic(true); - - for (int i=0; i<columnCount(); ++i) { - setFont(i, f); - } - } - } - - QString fileName() const - { - return data(0, FILENAME_USERROLE).toString(); - } - - QString altOrigin() const - { - return data(0, ALT_ORIGIN_USERROLE).toString(); - } - - bool hasAlts() const - { - return data(0, HAS_ALTS_USERROLE).toBool(); - } - - bool isArchive() const - { - return data(0, ARCHIVE_USERROLE).toBool(); - } - - FileEntry::Index fileIndex() const - { - static_assert(std::is_same_v<FileEntry::Index, unsigned int>); - return data(0, INDEX_USERROLE).toUInt(); - } - - bool canHide() const - { - return canHideFile(isArchive(), fileName()); - } - - bool canUnhide() const - { - return canUnhideFile(isArchive(), fileName()); - } - - bool canOpen() const - { - return canOpenFile(isArchive(), fileName()); - } - - bool canPreview(PluginContainer* pluginContainer) const - { - return canPreviewFile(pluginContainer, isArchive(), fileName()); - } - - bool operator<(const QTreeWidgetItem& other) const - { - const int column = treeWidget()->sortColumn(); - - if (column >= columnCount() || column >= other.columnCount()) { - // shouldn't happen - qWarning().nospace() << "ConflictItem::operator<() mistmatch in column count"; - return false; - } - - return (naturalCompare(text(column), other.text(column)) < 0); - } -}; - - -ModInfoDialog::ModInfoDialog(ModInfo::Ptr modInfo, const DirectoryEntry *directory, bool unmanaged, OrganizerCore *organizerCore, PluginContainer *pluginContainer, QWidget *parent) - : TutorableDialog("ModInfoDialog", parent), ui(new Ui::ModInfoDialog), m_ModInfo(modInfo), - m_ThumbnailMapper(this), m_RequestStarted(false), - m_NewFolderAction(nullptr), m_OpenAction(nullptr), m_PreviewAction(nullptr), - m_RenameAction(nullptr), m_DeleteAction(nullptr), m_HideAction(nullptr), - m_UnhideAction(nullptr), m_Directory(directory), m_Origin(nullptr), - m_OrganizerCore(organizerCore), m_PluginContainer(pluginContainer) +FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString &oldName) { - ui->setupUi(this); - this->setWindowTitle(modInfo->name()); - this->setWindowModality(Qt::WindowModal); - - m_RootPath = modInfo->absolutePath(); - - QString metaFileName = m_RootPath.mid(0).append("/meta.ini"); - m_Settings = new QSettings(metaFileName, QSettings::IniFormat); - - QLineEdit *modIDEdit = findChild<QLineEdit*>("modIDEdit"); - ui->modIDEdit->setValidator(new QIntValidator(modIDEdit)); - ui->modIDEdit->setText(QString("%1").arg(modInfo->getNexusID())); - - connect(ui->modIDEdit, SIGNAL(linkClicked(QString)), this, SLOT(linkClicked(QString))); - - QString gameName = modInfo->getGameName(); - ui->sourceGameEdit->addItem(organizerCore->managedGame()->gameName(), organizerCore->managedGame()->gameShortName()); - if (organizerCore->managedGame()->validShortNames().size() == 0) { - ui->sourceGameEdit->setDisabled(true); - } else { - for (auto game : pluginContainer->plugins<IPluginGame>()) { - for (QString gameName : organizerCore->managedGame()->validShortNames()) { - if (game->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { - ui->sourceGameEdit->addItem(game->gameName(), game->gameShortName()); - break; - } - } - } - } - ui->sourceGameEdit->setCurrentIndex(ui->sourceGameEdit->findData(gameName)); - - ui->commentsEdit->setText(modInfo->comments()); - ui->notesEdit->setText(modInfo->notes()); - - ui->descriptionView->setPage(new DescriptionPage()); - - connect(&m_ThumbnailMapper, SIGNAL(mapped(const QString&)), this, SIGNAL(thumbnailClickedSignal(const QString&))); - connect(this, SIGNAL(thumbnailClickedSignal(const QString&)), this, SLOT(thumbnailClicked(const QString&))); - connect(m_ModInfo.data(), SIGNAL(modDetailsUpdated(bool)), this, SLOT(modDetailsUpdated(bool))); - connect(ui->descriptionView->page(), SIGNAL(linkClicked(QUrl)), this, SLOT(linkClicked(QUrl))); - //TODO: No easy way to delegate links - //ui->descriptionView->page()->acceptNavigationRequest(QWebEnginePage::DelegateAllLinks); - - new QShortcut(QKeySequence::Delete, this, SLOT(delete_activated())); - - if (directory->originExists(ToWString(modInfo->name()))) { - m_Origin = &directory->getOriginByName(ToWString(modInfo->name())); - if (m_Origin->isDisabled()) { - m_Origin = nullptr; - } - } - - // refresh everything but the conflict lists, which are done in exec() because - // they depend on restoring the state to some widgets; this refresh has to be - // done here because some of the checks below depend on the ui to decide which - // tabs to enable - refreshFiles(); - - if (modInfo->hasFlag(ModInfo::FLAG_SEPARATOR)) - { - ui->tabWidget->setTabEnabled(TAB_TEXTFILES, false); - ui->tabWidget->setTabEnabled(TAB_INIFILES, false); - ui->tabWidget->setTabEnabled(TAB_IMAGES, false); - ui->tabWidget->setTabEnabled(TAB_ESPS, false); - ui->tabWidget->setTabEnabled(TAB_CONFLICTS, false); - //ui->tabWidget->setTabEnabled(TAB_CATEGORIES, false); - addCategories(CategoryFactory::instance(), modInfo->getCategories(), ui->categoriesTree->invisibleRootItem(), 0); - refreshPrimaryCategoriesBox(); - ui->tabWidget->setTabEnabled(TAB_NEXUS, false); - //ui->tabWidget->setTabEnabled(TAB_NOTES, false); - ui->tabWidget->setTabEnabled(TAB_FILETREE, false); - } - else if (unmanaged) - { - ui->tabWidget->setTabEnabled(TAB_INIFILES, false); - ui->tabWidget->setTabEnabled(TAB_CATEGORIES, false); - ui->tabWidget->setTabEnabled(TAB_NEXUS, false); - ui->tabWidget->setTabEnabled(TAB_FILETREE, false); - ui->tabWidget->setTabEnabled(TAB_NOTES, false); - ui->tabWidget->setTabEnabled(TAB_ESPS, false); - ui->tabWidget->setTabEnabled(TAB_TEXTFILES, false); - ui->tabWidget->setTabEnabled(TAB_IMAGES, false); - } else { - initFiletree(modInfo); - addCategories(CategoryFactory::instance(), modInfo->getCategories(), ui->categoriesTree->invisibleRootItem(), 0); - refreshPrimaryCategoriesBox(); - ui->tabWidget->setTabEnabled(TAB_TEXTFILES, ui->textFileList->count() != 0); - ui->tabWidget->setTabEnabled(TAB_IMAGES, ui->thumbnailArea->count() != 0); - ui->tabWidget->setTabEnabled(TAB_ESPS, (ui->inactiveESPList->count() != 0) || (ui->activeESPList->count() != 0)); - } - initINITweaks(); - - ui->tabWidget->setTabEnabled(TAB_CONFLICTS, m_Origin != nullptr); - - - ui->endorseBtn->setVisible(Settings::instance().endorsementIntegration()); - ui->endorseBtn->setEnabled((m_ModInfo->endorsedState() == ModInfo::ENDORSED_FALSE) || - (m_ModInfo->endorsedState() == ModInfo::ENDORSED_NEVER)); - - // activate first enabled tab - for (int i = 0; i < ui->tabWidget->count(); ++i) { - if (ui->tabWidget->isTabEnabled(i)) { - ui->tabWidget->setCurrentIndex(i); - break; - } - } - - if (ui->tabWidget->currentIndex() == TAB_NEXUS) { - activateNexusTab(); - } - - m_overwriteExpander.set(ui->overwriteExpander, ui->overwriteTree, true); - m_overwrittenExpander.set(ui->overwrittenExpander, ui->overwrittenTree, true); - m_nonconflictExpander.set(ui->noConflictExpander, ui->noConflictTree); - - - m_advancedConflictFilter.set(ui->conflictsAdvancedFilter); - m_advancedConflictFilter.changed = [&]{ refreshConflictLists(false, true); }; - - // left-elide the overwrites column so that the nearest are visible - ui->conflictsAdvancedList->setItemDelegateForColumn( - 0, new ElideLeftDelegate(ui->conflictsAdvancedList)); - - // left-elide the file column to see filenames - ui->conflictsAdvancedList->setItemDelegateForColumn( - 1, new ElideLeftDelegate(ui->conflictsAdvancedList)); - - // don't elide the overwritten by column so that the nearest are visible - - connect(ui->conflictsAdvancedShowNoConflict, &QCheckBox::clicked, [&] { - refreshConflictLists(false, true); - }); - - connect(ui->conflictsAdvancedShowAll, &QRadioButton::clicked, [&] { - refreshConflictLists(false, true); - }); - - connect(ui->conflictsAdvancedShowNearest, &QRadioButton::clicked, [&] { - refreshConflictLists(false, true); - }); + QString newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); + return renamer.rename(oldName, newName); } -ModInfoDialog::~ModInfoDialog() +ModInfoDialog::TabInfo::TabInfo(std::unique_ptr<ModInfoDialogTab> tab) + : tab(std::move(tab)), realPos(-1), widget(nullptr) { - m_ModInfo->setComments(ui->commentsEdit->text()); - //Avoid saving html stump if notes field is empty. - if (ui->notesEdit->toPlainText().isEmpty()) - m_ModInfo->setNotes(ui->notesEdit->toPlainText()); - else - m_ModInfo->setNotes(ui->notesEdit->toHtml()); - saveCategories(ui->categoriesTree->invisibleRootItem()); - saveIniTweaks(); // ini tweaks are written to the ini file directly. This is the only information not managed by ModInfo - delete ui->descriptionView->page(); - delete ui->descriptionView; - delete ui; - delete m_Settings; } - -int ModInfoDialog::exec() +bool ModInfoDialog::TabInfo::isVisible() const { - // no need to refresh the other stuff, that was done in the constructor - refreshConflictLists(true, true); - return TutorableDialog::exec(); + return (realPos != -1); } -void ModInfoDialog::initINITweaks() -{ - int numTweaks = m_Settings->beginReadArray("INI Tweaks"); - for (int i = 0; i < numTweaks; ++i) { - m_Settings->setArrayIndex(i); - QList<QListWidgetItem*> items = ui->iniTweaksList->findItems(m_Settings->value("name").toString(), Qt::MatchFixedString); - if (items.size() != 0) { - items.at(0)->setCheckState(Qt::Checked); - } - } - m_Settings->endArray(); -} -void ModInfoDialog::initFiletree(ModInfo::Ptr modInfo) +ModInfoDialog::ModInfoDialog( + MainWindow* mw, OrganizerCore* core, PluginContainer* plugin, + ModInfo::Ptr mod) : + TutorableDialog("ModInfoDialog", mw), + ui(new Ui::ModInfoDialog), m_mainWindow(mw), + m_core(core), m_plugin(plugin), m_initialTab(ModInfoTabIDs::None), + m_arrangingTabs(false) { - ui->fileTree = findChild<QTreeView*>("fileTree"); + ui->setupUi(this); - m_FileSystemModel = new QFileSystemModel(this); - m_FileSystemModel->setReadOnly(false); - m_FileSystemModel->setRootPath(m_RootPath); - ui->fileTree->setModel(m_FileSystemModel); - ui->fileTree->setRootIndex(m_FileSystemModel->index(m_RootPath)); - ui->fileTree->setColumnWidth(0, 300); + auto* sc = new QShortcut(QKeySequence::Delete, this); + connect(sc, &QShortcut::activated, [&]{ onDeleteShortcut(); }); - m_NewFolderAction = new QAction(tr("&New Folder"), ui->fileTree); - m_OpenAction = new QAction(tr("&Open"), ui->fileTree); - m_PreviewAction = new QAction(tr("&Preview"), ui->fileTree); - m_RenameAction = new QAction(tr("&Rename"), ui->fileTree); - m_DeleteAction = new QAction(tr("&Delete"), ui->fileTree); - m_HideAction = new QAction(tr("&Hide"), ui->fileTree); - m_UnhideAction = new QAction(tr("&Unhide"), ui->fileTree); + setMod(mod); + createTabs(); - connect(m_NewFolderAction, SIGNAL(triggered()), this, SLOT(createDirectoryTriggered())); - connect(m_OpenAction, SIGNAL(triggered()), this, SLOT(openTriggered())); - connect(m_PreviewAction, SIGNAL(triggered()), this, SLOT(previewTriggered())); - connect(m_RenameAction, SIGNAL(triggered()), this, SLOT(renameTriggered())); - connect(m_DeleteAction, SIGNAL(triggered()), this, SLOT(deleteTriggered())); - connect(m_HideAction, SIGNAL(triggered()), this, SLOT(hideTriggered())); - connect(m_UnhideAction, SIGNAL(triggered()), this, SLOT(unhideTriggered())); + connect(ui->tabWidget, &QTabWidget::currentChanged, [&]{ onTabSelectionChanged(); }); + connect(ui->tabWidget->tabBar(), &QTabBar::tabMoved, [&]{ onTabMoved(); }); + connect(ui->close, &QPushButton::clicked, [&]{ onCloseButton(); }); + connect(ui->previousMod, &QPushButton::clicked, [&]{ onPreviousMod(); }); + connect(ui->nextMod, &QPushButton::clicked, [&]{ onNextMod(); }); } +ModInfoDialog::~ModInfoDialog() = default; -int ModInfoDialog::tabIndex(const QString &tabId) +template <class T> +std::unique_ptr<ModInfoDialogTab> createTab(ModInfoDialog& d, ModInfoTabIDs id) { - for (int i = 0; i < ui->tabWidget->count(); ++i) { - if (ui->tabWidget->widget(i)->objectName() == tabId) { - return i; - } - } - return -1; -} - - -void ModInfoDialog::saveState(Settings& s) const -{ - s.directInterface().setValue("mod_info_tabs", saveTabState()); - s.directInterface().setValue("mod_info_conflicts", saveConflictsState()); - - s.directInterface().setValue( - "mod_info_conflicts_overwrite", - ui->overwriteTree->header()->saveState()); - - s.directInterface().setValue( - "mod_info_conflicts_noconflict", - ui->noConflictTree->header()->saveState()); - - s.directInterface().setValue( - "mod_info_conflicts_overwritten", - ui->overwrittenTree->header()->saveState()); - - s.directInterface().setValue( - "mod_info_advanced_conflicts", - ui->conflictsAdvancedList->header()->saveState()); -} - -void ModInfoDialog::restoreState(const Settings& s) -{ - restoreTabState(s.directInterface().value("mod_info_tabs").toByteArray()); - restoreConflictsState(s.directInterface().value("mod_info_conflicts").toByteArray()); - - ui->overwriteTree->header()->restoreState( - s.directInterface().value("mod_info_conflicts_overwrite").toByteArray()); - - ui->noConflictTree->header()->restoreState( - s.directInterface().value("mod_info_conflicts_noconflict").toByteArray()); - - ui->overwrittenTree->header()->restoreState( - s.directInterface().value("mod_info_conflicts_overwritten").toByteArray()); - - ui->conflictsAdvancedList->header()->restoreState( - s.directInterface().value("mod_info_advanced_conflicts").toByteArray()); + return std::make_unique<T>(ModInfoDialogTabContext( + *d.m_core, *d.m_plugin, &d, d.ui.get(), id, d.m_mod, d.getOrigin())); } -void ModInfoDialog::restoreTabState(const QByteArray &state) +void ModInfoDialog::createTabs() { - QDataStream stream(state); - int count = 0; - stream >> count; + m_tabs.clear(); - QStringList tabIds; + m_tabs.push_back(createTab<TextFilesTab>(*this, ModInfoTabIDs::TextFiles)); + m_tabs.push_back(createTab<IniFilesTab>(*this, ModInfoTabIDs::IniFiles)); + m_tabs.push_back(createTab<ImagesTab>(*this, ModInfoTabIDs::Images)); + m_tabs.push_back(createTab<ESPsTab>(*this, ModInfoTabIDs::Esps)); + m_tabs.push_back(createTab<ConflictsTab>(*this, ModInfoTabIDs::Conflicts)); + m_tabs.push_back(createTab<CategoriesTab>(*this, ModInfoTabIDs::Categories)); + m_tabs.push_back(createTab<NexusTab>(*this, ModInfoTabIDs::Nexus)); + m_tabs.push_back(createTab<NotesTab>(*this, ModInfoTabIDs::Notes)); + m_tabs.push_back(createTab<FileTreeTab>(*this, ModInfoTabIDs::Filetree)); - // first, only determine the new mapping - for (int newPos = 0; newPos < count; ++newPos) { - QString tabId; - stream >> tabId; - tabIds.append(tabId); - int oldPos = tabIndex(tabId); - if (oldPos != -1) { - m_RealTabPos[newPos] = oldPos; - } else { - m_RealTabPos[newPos] = newPos; - } - } - // then actually move the tabs - QTabBar *tabBar = ui->tabWidget->findChild<QTabBar*>("qt_tabwidget_tabbar"); // magic name = bad - ui->tabWidget->blockSignals(true); - for (int newPos = 0; newPos < count; ++newPos) { - QString tabId = tabIds.at(newPos); - int oldPos = tabIndex(tabId); - tabBar->moveTab(oldPos, newPos); + // check for tabs in the ui not having a corresponding tab in the list + int count = ui->tabWidget->count(); + if (count < 0 || count > static_cast<int>(m_tabs.size())) { + qCritical() << "mod info dialog has more tabs than expected"; + count = static_cast<int>(m_tabs.size()); } - ui->tabWidget->blockSignals(false); -} - -void ModInfoDialog::restoreConflictsState(const QByteArray &state) -{ - QDataStream stream(state); - bool overwriteExpanded = false; - bool overwrittenExpanded = false; - bool noConflictExpanded = false; + // for each tab in the widget; connects the widgets with the tab objects + // + for (int i=0; i<count; ++i) { + auto& tabInfo = m_tabs[static_cast<std::size_t>(i)]; - stream >> overwriteExpanded >> overwrittenExpanded >> noConflictExpanded; + // remembering tab information so tabs can be removed and re-added + tabInfo.widget = ui->tabWidget->widget(i); + tabInfo.caption = ui->tabWidget->tabText(i); + tabInfo.icon = ui->tabWidget->tabIcon(i); - if (stream.status() == QDataStream::Ok) { - m_overwriteExpander.toggle(overwriteExpanded); - m_overwrittenExpander.toggle(overwrittenExpanded); - m_nonconflictExpander.toggle(noConflictExpanded); - } + connect( + tabInfo.tab.get(), &ModInfoDialogTab::originModified, + [this](int originID){ onOriginModified(originID); }); - int index = 0; - bool noConflictChecked = false; - bool showAllChecked = false; - bool showNearestChecked = false; + connect( + tabInfo.tab.get(), &ModInfoDialogTab::modOpen, + [&](const QString& name){ setMod(name); update(); }); - stream >> index >> noConflictChecked >> showAllChecked >> showNearestChecked; + connect( + tabInfo.tab.get(), &ModInfoDialogTab::hasDataChanged, + [&]{ setTabsColors(); }); - if (stream.status() == QDataStream::Ok) { - ui->tabConflictsTabs->setCurrentIndex(index); - ui->conflictsAdvancedShowNoConflict->setChecked(noConflictChecked); - ui->conflictsAdvancedShowAll->setChecked(showAllChecked); - ui->conflictsAdvancedShowNearest->setChecked(showNearestChecked); + connect( + tabInfo.tab.get(), &ModInfoDialogTab::wantsFocus, + [&, id=tabInfo.tab->tabID()]{ switchToTab(id); }); } } -QByteArray ModInfoDialog::saveTabState() const -{ - QByteArray result; - QDataStream stream(&result, QIODevice::WriteOnly); - stream << ui->tabWidget->count(); - for (int i = 0; i < ui->tabWidget->count(); ++i) { - stream << ui->tabWidget->widget(i)->objectName(); - } - - return result; -} - -QByteArray ModInfoDialog::saveConflictsState() const -{ - QByteArray result; - QDataStream stream(&result, QIODevice::WriteOnly); - - stream - << m_overwriteExpander.opened() - << m_overwrittenExpander.opened() - << m_nonconflictExpander.opened() - << ui->tabConflictsTabs->currentIndex() - << ui->conflictsAdvancedShowNoConflict->isChecked() - << ui->conflictsAdvancedShowAll->isChecked() - << ui->conflictsAdvancedShowNearest->isChecked(); - - return result; -} - -void ModInfoDialog::refreshLists() -{ - refreshConflictLists(true, true); - refreshFiles(); -} - -void ModInfoDialog::refreshConflictLists( - bool refreshGeneral, bool refreshAdvanced) +int ModInfoDialog::exec() { - int numNonConflicting = 0; - int numOverwrite = 0; - int numOverwritten = 0; + // whether to select the first tab; if the main window requested a specific + // tab, it is selected when encountered in update() + const auto selectFirst = (m_initialTab == ModInfoTabIDs::None); - if (refreshGeneral) { - ui->overwriteTree->clear(); - ui->overwrittenTree->clear(); - ui->noConflictTree->clear(); - } + update(true); - if (refreshAdvanced) { - ui->conflictsAdvancedList->clear(); + if (selectFirst && ui->tabWidget->count() > 0) { + ui->tabWidget->setCurrentIndex(0); } - if (m_Origin != nullptr) { - std::vector<FileEntry::Ptr> files = m_Origin->getFiles(); - - for (const auto& file : m_Origin->getFiles()) { - const QString relativeName = QDir::fromNativeSeparators(ToQString(file->getRelativePath())); - const QString fileName = relativeName.mid(0).prepend(m_RootPath); - - bool archive = false; - const int fileOrigin = file->getOrigin(archive); - const auto& alternatives = file->getAlternatives(); - - if (refreshGeneral) { - if (fileOrigin == m_Origin->getID()) { - if (!alternatives.empty()) { - ui->overwriteTree->addTopLevelItem(createOverwriteItem( - file->getIndex(), archive, fileName, relativeName, alternatives)); - - ++numOverwrite; - } else { - // otherwise, put the file in the noconflict tree - ui->noConflictTree->addTopLevelItem(createNoConflictItem( - file->getIndex(), archive, fileName, relativeName)); - - ++numNonConflicting; - } - } else { - ui->overwrittenTree->addTopLevelItem(createOverwrittenItem( - file->getIndex(), fileOrigin, archive, fileName, relativeName)); - - ++numOverwritten; - } - } - - if (refreshAdvanced) { - auto* advancedItem = createAdvancedConflictItem( - file->getIndex(), fileOrigin, archive, - fileName, relativeName, alternatives); - - if (advancedItem) { - ui->conflictsAdvancedList->addTopLevelItem(advancedItem); - } - } - } - } - - if (refreshGeneral) { - ui->overwriteCount->display(numOverwrite); - ui->overwrittenCount->display(numOverwritten); - ui->noConflictCount->display(numNonConflicting); - } + return TutorableDialog::exec(); } -QTreeWidgetItem* ModInfoDialog::createOverwriteItem( - FileEntry::Index index, bool archive, - const QString& fileName, const QString& relativeName, - const FileEntry::AlternativesVector& alternatives) +void ModInfoDialog::setMod(ModInfo::Ptr mod) { - QString altString; + Q_ASSERT(mod); + m_mod = mod; - for (const auto& alt : alternatives) { - if (!altString.isEmpty()) { - altString += ", "; - } - - altString += ToQString(m_Directory->getOriginByID(alt.first).getName()); + // resetting the first activation flag so selecting tabs will trigger it + // again + for (auto& tabInfo : m_tabs) { + tabInfo.tab->resetFirstActivation(); } - - QStringList fields(relativeName); - fields.append(altString); - - const auto origin = ToQString(m_Directory->getOriginByID(alternatives.back().first).getName()); - - return new ConflictItem(fields, index, fileName, true, origin, archive); -} - -QTreeWidgetItem* ModInfoDialog::createNoConflictItem( - FileEntry::Index index, bool archive, - const QString& fileName, const QString& relativeName) -{ - return new ConflictItem({relativeName}, index, fileName, false, "", archive); -} - -QTreeWidgetItem* ModInfoDialog::createOverwrittenItem( - FileEntry::Index index, int fileOrigin, bool archive, - const QString& fileName, const QString& relativeName) -{ - const FilesOrigin &realOrigin = m_Directory->getOriginByID(fileOrigin); - - QStringList fields(relativeName); - fields.append(ToQString(realOrigin.getName())); - - return new ConflictItem( - fields, index, fileName, true, ToQString(realOrigin.getName()), archive); } -QTreeWidgetItem* ModInfoDialog::createAdvancedConflictItem( - FileEntry::Index index,int fileOrigin, bool archive, - const QString& fileName, const QString& relativeName, - const MOShared::FileEntry::AlternativesVector& alternatives) +void ModInfoDialog::setMod(const QString& name) { - QString before, after; - - if (!alternatives.empty()) { - int beforePrio = 0; - int afterPrio = std::numeric_limits<int>::max(); - - for (const auto& alt : alternatives) - { - const auto altOrigin = m_Directory->getOriginByID(alt.first); - - if (ui->conflictsAdvancedShowAll->isChecked()) { - // fills 'before' and 'after' with all the alternatives that come - // before and after this mod in terms of priority - - if (altOrigin.getPriority() < m_Origin->getPriority()) { - // add all the mods having a lower priority than this one - if (!before.isEmpty()) { - before += ", "; - } - - before += ToQString(altOrigin.getName()); - } else if (altOrigin.getPriority() > m_Origin->getPriority()) { - // add all the mods having a higher priority than this one - if (!after.isEmpty()) { - after += ", "; - } - - after += ToQString(altOrigin.getName()); - } - } else { - // keep track of the nearest mods that come before and after this one - // in terms of priority - - if (altOrigin.getPriority() < m_Origin->getPriority()) { - // the alternative has a lower priority than this mod - - if (altOrigin.getPriority() > beforePrio) { - // the alternative has a higher priority and therefore is closer - // to this mod, use it - before = ToQString(altOrigin.getName()); - beforePrio = altOrigin.getPriority(); - } - } - - if (altOrigin.getPriority() > m_Origin->getPriority()) { - // the alternative has a higher priority than this mod - - if (altOrigin.getPriority() < afterPrio) { - // the alternative has a lower priority and there is closer - // to this mod, use it - after = ToQString(altOrigin.getName()); - afterPrio = altOrigin.getPriority(); - } - } - } - } - - // the primary origin is never in the list of alternatives, so it has to - // be handled separately - // - // if 'after' is not empty, it means at least one alternative with a higher - // priority than this mod was found; if the user only wants to see the - // nearest mods, it's not worth checking for the primary origin because it - // will always have a higher priority than the alternatives (or it wouldn't - // be the primary) - if (after.isEmpty() || ui->conflictsAdvancedShowAll->isChecked()) { - FilesOrigin &realOrigin = m_Directory->getOriginByID(fileOrigin); - - // if no mods overwrite this file, the primary origin is the same as this - // mod, so ignore that - if (realOrigin.getID() != m_Origin->getID()) { - if (!after.isEmpty()) { - after += ", "; - } - - after += ToQString(realOrigin.getName()); - } - } - } - - bool hasAlts = !before.isEmpty() || !after.isEmpty(); - - if (!ui->conflictsAdvancedShowNoConflict->isChecked()) { - // if both before and after are empty, it means this file has no conflicts - // at all, only display it if the user wants it - if (!hasAlts) { - return nullptr; - } + unsigned int index = ModInfo::getIndex(name); + if (index == UINT_MAX) { + qCritical() << "failed to resolve mod name " << name; + return; } - bool matched = m_advancedConflictFilter.matches([&](auto&& what) { - return - before.contains(what, Qt::CaseInsensitive) || - relativeName.contains(what, Qt::CaseInsensitive) || - after.contains(what, Qt::CaseInsensitive); - }); - - if (!matched) { - return nullptr; + auto mod = ModInfo::getByIndex(index); + if (!mod) { + qCritical() << "mod by index " << index << " is null"; + return; } - return new ConflictItem( - {before, relativeName, after}, index, fileName, hasAlts, "", archive); + setMod(mod); } -void ModInfoDialog::refreshFiles() +void ModInfoDialog::selectTab(ModInfoTabIDs id) { - if (m_RootPath.length() > 0) { - QDirIterator dirIterator(m_RootPath, QDir::Files, QDirIterator::Subdirectories); - while (dirIterator.hasNext()) { - QString fileName = dirIterator.next(); - - if (fileName.endsWith(".txt", Qt::CaseInsensitive)) { - ui->textFileList->addItem(fileName.mid(m_RootPath.length() + 1)); - } else if ((fileName.endsWith(".ini", Qt::CaseInsensitive) || fileName.endsWith(".cfg", Qt::CaseInsensitive)) && - !fileName.endsWith("meta.ini")) { - QString namePart = fileName.mid(m_RootPath.length() + 1); - if (namePart.startsWith("INI Tweaks", Qt::CaseInsensitive)) { - QListWidgetItem *newItem = new QListWidgetItem(namePart.mid(11), ui->iniTweaksList); - newItem->setData(Qt::UserRole, namePart); - newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); - newItem->setCheckState(Qt::Unchecked); - ui->iniTweaksList->addItem(newItem); - } else { - ui->iniFileList->addItem(namePart); - } - } else if (fileName.endsWith(".esp", Qt::CaseInsensitive) || - fileName.endsWith(".esm", Qt::CaseInsensitive) || - fileName.endsWith(".esl", Qt::CaseInsensitive)) { - QString relativePath = fileName.mid(m_RootPath.length() + 1); - if (relativePath.contains('/')) { - QFileInfo fileInfo(fileName); - QListWidgetItem *newItem = new QListWidgetItem(fileInfo.fileName()); - newItem->setData(Qt::UserRole, relativePath); - ui->inactiveESPList->addItem(newItem); - } else { - ui->activeESPList->addItem(relativePath); - } - } else if ((fileName.endsWith(".png", Qt::CaseInsensitive)) || - (fileName.endsWith(".jpg", Qt::CaseInsensitive))) { - QImage image = QImage(fileName); - if (!image.isNull()) { - if (static_cast<float>(image.width()) / static_cast<float>(image.height()) > 1.34) { - image = image.scaledToWidth(128); - } else { - image = image.scaledToHeight(96); - } - - QPushButton *thumbnailButton = new QPushButton(QPixmap::fromImage(image), ""); - thumbnailButton->setIconSize(QSize(image.width(), image.height())); - connect(thumbnailButton, SIGNAL(clicked()), &m_ThumbnailMapper, SLOT(map())); - m_ThumbnailMapper.setMapping(thumbnailButton, fileName); - ui->thumbnailArea->addWidget(thumbnailButton); - } - } - } - } -} - -void ModInfoDialog::addCategories(const CategoryFactory &factory, const std::set<int> &enabledCategories, QTreeWidgetItem *root, int rootLevel) -{ - for (int i = 0; i < static_cast<int>(factory.numCategories()); ++i) { - if (factory.getParentID(i) != rootLevel) { - continue; - } - int categoryID = factory.getCategoryID(i); - QTreeWidgetItem *newItem - = new QTreeWidgetItem(QStringList(factory.getCategoryName(i))); - newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); - newItem->setCheckState(0, enabledCategories.find(categoryID) - != enabledCategories.end() - ? Qt::Checked - : Qt::Unchecked); - newItem->setData(0, Qt::UserRole, categoryID); - if (factory.hasChildren(i)) { - addCategories(factory, enabledCategories, newItem, categoryID); - } - root->addChild(newItem); + if (!isVisible()) { + // can't select a tab if the dialog hasn't been properly updated yet + m_initialTab = id; + return; } -} - -void ModInfoDialog::saveCategories(QTreeWidgetItem *currentNode) -{ - for (int i = 0; i < currentNode->childCount(); ++i) { - QTreeWidgetItem *childNode = currentNode->child(i); - m_ModInfo->setCategory(childNode->data(0, Qt::UserRole).toInt(), childNode->checkState(0)); - saveCategories(childNode); - } + switchToTab(id); } - -void ModInfoDialog::on_closeButton_clicked() +ModInfoDialog::TabInfo* ModInfoDialog::currentTab() { - if (allowNavigateFromTXT() && allowNavigateFromINI()) { - this->close(); + const auto index = ui->tabWidget->currentIndex(); + if (index < 0) { + return nullptr; } -} - - -QString ModInfoDialog::getModVersion() const -{ - return m_Settings->value("version", "").toString(); -} - - -const int ModInfoDialog::getModID() const -{ - return m_Settings->value("modid", 0).toInt(); -} - -void ModInfoDialog::openTab(int tab) -{ - QTabWidget *tabWidget = findChild<QTabWidget*>("tabWidget"); - if (tabWidget->isTabEnabled(tab)) { - tabWidget->setCurrentIndex(tab); + // looking for the actual tab at that position + for (auto& tabInfo : m_tabs) { + if (tabInfo.realPos == index) { + return &tabInfo; + } } -} -void ModInfoDialog::thumbnailClicked(const QString &fileName) -{ - QLabel *imageLabel = findChild<QLabel*>("imageLabel"); - imageLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); - QImage image(fileName); - if (static_cast<float>(image.width()) / static_cast<float>(image.height()) > 1.34) { - image = image.scaledToWidth(imageLabel->geometry().width()); - } else { - image = image.scaledToHeight(imageLabel->geometry().height()); - } - imageLabel->setPixmap(QPixmap::fromImage(image)); + return nullptr; } -bool ModInfoDialog::allowNavigateFromTXT() +void ModInfoDialog::update(bool firstTime) { - if (ui->saveTXTButton->isEnabled()) { - int res = QMessageBox::question(this, tr("Save changes?"), tr("Save changes to \"%1\"?").arg(ui->textFileView->property("currentFile").toString()), - QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); - if (res == QMessageBox::Cancel) { - return false; - } else if (res == QMessageBox::Yes) { - saveCurrentTextFile(); - } - } - return true; -} - + // remembering the current selection, will be restored if the tab still + // exists + const int oldTab = ui->tabWidget->currentIndex(); -bool ModInfoDialog::allowNavigateFromINI() -{ - if (ui->saveButton->isEnabled()) { - int res = QMessageBox::question(this, tr("Save changes?"), tr("Save changes to \"%1\"?").arg(ui->iniFileView->property("currentFile").toString()), - QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); - if (res == QMessageBox::Cancel) { - return false; - } else if (res == QMessageBox::Yes) { - saveCurrentIniFile(); - } - } - return true; -} + setWindowTitle(m_mod->name()); + // rebuilding the tab widget if needed depending on what tabs are valid for + // the current mod + setTabsVisibility(firstTime); -void ModInfoDialog::on_textFileList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous) -{ - QString fullPath = m_RootPath + "/" + current->text(); + // updating the data in all tabs + updateTabs(); - QVariant currentFile = ui->textFileView->property("currentFile"); - if (currentFile.isValid() && (currentFile.toString() == fullPath)) { - // the new file is the same as the currently displayed file. May be the result of a cancelation - return; + // switching to the initial tab, if any + if (m_initialTab != ModInfoTabIDs::None) { + switchToTab(m_initialTab); + m_initialTab = ModInfoTabIDs::None; } - if (allowNavigateFromTXT()) { - openTextFile(fullPath); - } else { - ui->textFileList->setCurrentItem(previous, QItemSelectionModel::Current); + if (ui->tabWidget->currentIndex() == oldTab) { + if (auto* tabInfo=currentTab()) { + // activated() has to be fired manually because the tab index hasn't been + // changed + tabInfo->tab->activated(); + } else { + qCritical() << "tab index " << oldTab << " not found"; + } } } - -void ModInfoDialog::openTextFile(const QString &fileName) -{ - QString encoding; - ui->textFileView->setText(MOBase::readFileText(fileName, &encoding)); - ui->textFileView->setProperty("currentFile", fileName); - ui->textFileView->setProperty("encoding", encoding); - ui->saveTXTButton->setEnabled(false); -} - - -void ModInfoDialog::openIniFile(const QString &fileName) +void ModInfoDialog::setTabsVisibility(bool firstTime) { - QFile iniFile(fileName); - iniFile.open(QIODevice::ReadOnly); - QByteArray buffer = iniFile.readAll(); + // this flag is picked up by onTabSelectionChanged() to avoid triggering + // activation events while moving tabs around + QScopedValueRollback arrangingTabs(m_arrangingTabs, true); - QTextCodec *codec = QTextCodec::codecForUtfText(buffer, QTextCodec::codecForName("utf-8")); - QTextEdit *iniFileView = findChild<QTextEdit*>("iniFileView"); - iniFileView->setText(codec->toUnicode(buffer)); - iniFileView->setProperty("currentFile", fileName); - iniFileView->setProperty("encoding", codec->name()); - iniFile.close(); + // one bool per tab to indicate whether the tab should be visible + std::vector<bool> visibility(m_tabs.size()); - ui->saveButton->setEnabled(false); -} + bool changed = false; + for (std::size_t i=0; i<m_tabs.size(); ++i) { + const auto& tabInfo = m_tabs[i]; -void ModInfoDialog::saveIniTweaks() -{ - m_Settings->remove("INI Tweaks"); - m_Settings->beginWriteArray("INI Tweaks"); + bool visible = true; - int countEnabled = 0; - for (int i = 0; i < ui->iniTweaksList->count(); ++i) { - if (ui->iniTweaksList->item(i)->checkState() == Qt::Checked) { - m_Settings->setArrayIndex(countEnabled++); - m_Settings->setValue("name", ui->iniTweaksList->item(i)->text()); + // a tab is visible if it can handle the current mod + if (m_mod->hasFlag(ModInfo::FLAG_FOREIGN)) { + visible = tabInfo.tab->canHandleUnmanaged(); + } else if (m_mod->hasFlag(ModInfo::FLAG_SEPARATOR)) { + visible = tabInfo.tab->canHandleSeparators(); } - } - m_Settings->endArray(); -} + // if the visibility of this tab is changing, set changed to true because + // the tabs have to be rebuilt + const auto currentlyVisible = (ui->tabWidget->indexOf(tabInfo.widget) != -1); + if (visible != currentlyVisible) { + changed = true; + } -void ModInfoDialog::on_iniFileList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous) -{ - QString fullPath = m_RootPath + "/" + current->text(); - - QVariant currentFile = ui->iniFileView->property("currentFile"); - if (currentFile.isValid() && (currentFile.toString() == fullPath)) { - // the new file is the same as the currently displayed file. May be the result of a cancelation - return; - } - - if (allowNavigateFromINI()) { - openIniFile(fullPath); - } else { - ui->iniFileList->setCurrentItem(previous, QItemSelectionModel::Current); + visibility[i] = visible; } -} - -void ModInfoDialog::on_iniTweaksList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous) -{ - QString fullPath = m_RootPath + "/" + current->data(Qt::UserRole).toString(); - - QVariant currentFile = ui->iniFileView->property("currentFile"); - if (currentFile.isValid() && (currentFile.toString() == fullPath)) { - // the new file is the same as the currently displayed file. May be the result of a cancelation + // the tabs have to be rebuilt the first time the dialog is shown, or when + // the visibility of any tab has changed + if (!firstTime && !changed) { return; } - if (allowNavigateFromINI()) { - openIniFile(fullPath); - } else { - ui->iniFileList->setCurrentItem(previous, QItemSelectionModel::Current); - } - -} - - -void ModInfoDialog::on_saveButton_clicked() -{ - saveCurrentIniFile(); -} - - -void ModInfoDialog::on_saveTXTButton_clicked() -{ - saveCurrentTextFile(); -} - - -void ModInfoDialog::saveCurrentTextFile() -{ - QVariant fileNameVar = ui->textFileView->property("currentFile"); - QVariant encodingVar = ui->textFileView->property("encoding"); - if (fileNameVar.isValid() && encodingVar.isValid()) { - QString fileName = fileNameVar.toString(); - QFile txtFile(fileName); - txtFile.open(QIODevice::WriteOnly); - txtFile.resize(0); - QTextCodec *codec = QTextCodec::codecForName(encodingVar.toString().toUtf8()); - QString data = ui->textFileView->toPlainText().replace("\n", "\r\n"); - txtFile.write(codec->fromUnicode(data)); - } else { - reportError("no file selected"); - } - ui->saveTXTButton->setEnabled(false); -} - + // save the current order (if necessary) because some tabs will be removed and + // others added + saveTabOrder(Settings::instance()); -void ModInfoDialog::saveCurrentIniFile() -{ - QVariant fileNameVar = ui->iniFileView->property("currentFile"); - QVariant encodingVar = ui->iniFileView->property("encoding"); - if (fileNameVar.isValid() && !fileNameVar.toString().isEmpty()) { - QString fileName = fileNameVar.toString(); - QDir().mkpath(QFileInfo(fileName).absolutePath()); - QFile txtFile(fileName); - txtFile.open(QIODevice::WriteOnly); - txtFile.resize(0); - QTextCodec *codec = QTextCodec::codecForName(encodingVar.toString().toUtf8()); - QString data = ui->iniFileView->toPlainText().replace("\n", "\r\n"); - txtFile.write(codec->fromUnicode(data)); - } else { - reportError("no file selected"); + // remember selection, if any + auto sel = ModInfoTabIDs::None; + if (const auto* tabInfo=currentTab()) { + sel = tabInfo->tab->tabID(); } - ui->saveButton->setEnabled(false); -} - -void ModInfoDialog::on_iniFileView_textChanged() -{ - QPushButton* saveButton = findChild<QPushButton*>("saveButton"); - saveButton->setEnabled(true); + // removes all tabs and re-adds the visible ones + reAddTabs(visibility, sel); } - -void ModInfoDialog::on_textFileView_textChanged() +void ModInfoDialog::reAddTabs( + const std::vector<bool>& visibility, ModInfoTabIDs sel) { - ui->saveTXTButton->setEnabled(true); -} - + Q_ASSERT(visibility.size() == m_tabs.size()); -void ModInfoDialog::on_activateESP_clicked() -{ - QListWidget *activeESPList = findChild<QListWidget*>("activeESPList"); - QListWidget *inactiveESPList = findChild<QListWidget*>("inactiveESPList"); + // ordered tab names from settings + const auto orderedNames = getOrderedTabNames(); - int selectedRow = inactiveESPList->currentRow(); - if (selectedRow < 0) { - return; - } - - QListWidgetItem *selectedItem = inactiveESPList->takeItem(selectedRow); - - QDir root(m_RootPath); - bool renamed = false; - - while (root.exists(selectedItem->text())) { - bool okClicked = false; - QString newName = QInputDialog::getText(this, tr("File Exists"), tr("A file with that name exists, please enter a new one"), QLineEdit::Normal, selectedItem->text(), &okClicked); - if (!okClicked) { - inactiveESPList->insertItem(selectedRow, selectedItem); - return; - } else if (newName.size() > 0) { - selectedItem->setText(newName); - renamed = true; - } - } + // whether the tabs can be sorted; if the object name of a tab widget is not + // found in orderedNames, the list cannot be sorted safely + bool canSort = true; - if (root.rename(selectedItem->data(Qt::UserRole).toString(), selectedItem->text())) { - activeESPList->addItem(selectedItem); - if (renamed) { - selectedItem->setData(Qt::UserRole, QVariant()); + // gathering visible tabs + std::vector<TabInfo*> visibleTabs; + for (std::size_t i=0; i<m_tabs.size(); ++i) { + if (!visibility[i]) { + // this tab is not visible, skip it + continue; } - } else { - inactiveESPList->insertItem(selectedRow, selectedItem); - reportError(tr("failed to move file")); - } -} - -void ModInfoDialog::on_deactivateESP_clicked() -{ - QListWidget *activeESPList = findChild<QListWidget*>("activeESPList"); - QListWidget *inactiveESPList = findChild<QListWidget*>("inactiveESPList"); - - int selectedRow = activeESPList->currentRow(); - if (selectedRow < 0) { - return; - } - - QDir root(m_RootPath); + // this tab is visible + visibleTabs.push_back(&m_tabs[i]); - QListWidgetItem *selectedItem = activeESPList->takeItem(selectedRow); + if (canSort) { + // make sure the widget object name is found in the list + const auto objectName = m_tabs[i].widget->objectName(); + auto itor = std::find(orderedNames.begin(), orderedNames.end(), objectName); - // if we moved the file from optional to active in this session, we move the file back to - // where it came from. Otherwise, it is moved to the new folder "optional" - if (selectedItem->data(Qt::UserRole).isNull()) { - selectedItem->setData(Qt::UserRole, QString("optional/") + selectedItem->text()); - if (!root.exists("optional")) { - if (!root.mkdir("optional")) { - reportError(tr("failed to create directory \"optional\"")); - activeESPList->insertItem(selectedRow, selectedItem); - return; + if (itor == orderedNames.end()) { + // this shouldn't happen, it means there's a tab in the UI that's no + // in the list + qCritical() << "can't sort tabs, '" << objectName << "' not found"; + canSort = false; } } } - if (root.rename(selectedItem->text(), selectedItem->data(Qt::UserRole).toString())) { - inactiveESPList->addItem(selectedItem); - } else { - activeESPList->insertItem(selectedRow, selectedItem); - } -} - -void ModInfoDialog::on_visitNexusLabel_linkActivated(const QString &link) -{ - emit linkActivated(link); -} - -void ModInfoDialog::linkClicked(const QUrl &url) -{ - //Ideally we'd ask the mod for the game and the web service then pass the game - //and URL to the web service - if (NexusInterface::instance(m_PluginContainer)->isURLGameRelated(url)) { - emit linkActivated(url.toString()); - } else { - shell::OpenLink(url); - } -} - -void ModInfoDialog::linkClicked(QString url) -{ - emit linkActivated(url); -} - - -void ModInfoDialog::refreshNexusData(int modID) -{ - if ((!m_RequestStarted) && (modID > 0)) { - m_RequestStarted = true; - - m_ModInfo->updateNXMInfo(); - - MessageDialog::showMessage(tr("Info requested, please wait"), this); - } -} - + // sorting tabs + if (canSort) { + std::sort(visibleTabs.begin(), visibleTabs.end(), [&](auto&& a, auto&& b){ + // looking the names in the ordered list + auto aItor = std::find( + orderedNames.begin(), orderedNames.end(), + a->widget->objectName()); -QString ModInfoDialog::getFileCategory(int categoryID) -{ - switch (categoryID) { - case 1: return tr("Main"); - case 2: return tr("Update"); - case 3: return tr("Optional"); - case 4: return tr("Old"); - case 5: return tr("Miscellaneous"); - case 6: return tr("Deleted"); - default: return tr("Unknown"); - } -} + auto bItor = std::find( + orderedNames.begin(), orderedNames.end(), + b->widget->objectName()); + // this shouldn't happen, it was checked above + Q_ASSERT(aItor != orderedNames.end() && bItor != orderedNames.end()); -void ModInfoDialog::updateVersionColor() -{ -// QPalette versionColor; - if (m_ModInfo->getVersion() != m_ModInfo->getNewestVersion()) { - ui->versionEdit->setStyleSheet("color: red"); -// versionColor.setColor(QPalette::Text, Qt::red); - ui->versionEdit->setToolTip(tr("Current Version: %1").arg(m_ModInfo->getNewestVersion().canonicalString())); - } else { - ui->versionEdit->setStyleSheet("color: green"); -// versionColor.setColor(QPalette::Text, Qt::green); - ui->versionEdit->setToolTip(tr("No update available")); + return (aItor < bItor); + }); } -// ui->versionEdit->setPalette(versionColor); -} -void ModInfoDialog::modDetailsUpdated(bool success) -{ - QString nexusDescription = m_ModInfo->getNexusDescription(); - QString descriptionAsHTML = "<html>" - "<head><style class=\"nexus-description\">body {font-style: sans-serif; background: #707070; } a { color: #5EA2E5; }</style></head>" - "<body>%1</body>" - "</html>"; + // removing all tabs + ui->tabWidget->clear(); - if (!nexusDescription.isEmpty()) { - descriptionAsHTML = descriptionAsHTML.arg(BBCode::convertToHTML(nexusDescription)); - } else { - descriptionAsHTML = descriptionAsHTML.arg(tr("<div style=\"text-align: center;\"><h1>Uh oh!</h1><p>Sorry, there is no description available for this mod. :(</p></div>")); + // reset real positions + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; } - ui->descriptionView->page()->setHtml(descriptionAsHTML); + // add visible tabs + for (std::size_t i=0; i<visibleTabs.size(); ++i) { + auto& tabInfo = *visibleTabs[i]; - updateVersionColor(); -} + // remembering real position + tabInfo.realPos = static_cast<int>(i); + // adding tab + ui->tabWidget->addTab(tabInfo.widget, tabInfo.icon, tabInfo.caption); -void ModInfoDialog::activateNexusTab() -{ - QLineEdit *modIDEdit = findChild<QLineEdit*>("modIDEdit"); - int modID = modIDEdit->text().toInt(); - if (modID > 0) { - QString nexusLink = NexusInterface::instance(m_PluginContainer)->getModURL(modID, m_ModInfo->getGameName()); - QLabel *visitNexusLabel = findChild<QLabel*>("visitNexusLabel"); - visitNexusLabel->setText(tr("<a href=\"%1\">Visit on Nexus</a>").arg(nexusLink)); - visitNexusLabel->setToolTip(nexusLink); - m_ModInfo->setURL(nexusLink); - - if (m_ModInfo->getNexusDescription().isEmpty() || QDateTime::currentDateTimeUtc() >= m_ModInfo->getLastNexusQuery().addDays(1)) { - refreshNexusData(modID); - } else { - modDetailsUpdated(true); + // selecting + if (tabInfo.tab->tabID() == sel) { + ui->tabWidget->setCurrentIndex(static_cast<int>(i)); } - } else - modDetailsUpdated(true); - QLineEdit *versionEdit = findChild<QLineEdit*>("versionEdit"); - QString currentVersion = m_Settings->value("version", "???").toString(); - versionEdit->setText(currentVersion); - ui->customUrlLineEdit->setText(m_ModInfo->getURL()); -} - - -void ModInfoDialog::on_tabWidget_currentChanged(int index) -{ - if (index == TAB_NEXUS || m_RealTabPos[index] == TAB_NEXUS) { - activateNexusTab(); } } - -void ModInfoDialog::on_modIDEdit_editingFinished() +void ModInfoDialog::updateTabs(bool becauseOriginChanged) { - int oldID = m_Settings->value("modid", 0).toInt(); - int modID = ui->modIDEdit->text().toInt(); - if (oldID != modID){ - m_ModInfo->setNexusID(modID); + auto* origin = getOrigin(); - ui->descriptionView->page()->setHtml(""); - if (modID != 0) { - m_RequestStarted = false; - refreshNexusData(modID); - } - } -} + // list of tabs that should be updated + std::vector<TabInfo*> interestedTabs; -void ModInfoDialog::on_sourceGameEdit_currentIndexChanged(int) -{ - for (auto game : m_PluginContainer->plugins<IPluginGame>()) { - if (game->gameName() == ui->sourceGameEdit->currentText()) { - m_ModInfo->setGameName(game->gameShortName()); - return; + for (auto& tabInfo : m_tabs) { + // don't touch invisible tabs + if (!tabInfo.isVisible()) { + continue; } - } -} - -void ModInfoDialog::on_versionEdit_editingFinished() -{ - VersionInfo version(ui->versionEdit->text()); - m_ModInfo->setVersion(version); - updateVersionColor(); -} -void ModInfoDialog::on_customUrlLineEdit_editingFinished() -{ - m_ModInfo->setURL(ui->customUrlLineEdit->text()); -} - -bool ModInfoDialog::recursiveDelete(const QModelIndex &index) -{ - for (int childRow = 0; childRow < m_FileSystemModel->rowCount(index); ++childRow) { - QModelIndex childIndex = m_FileSystemModel->index(childRow, 0, index); - if (m_FileSystemModel->isDir(childIndex)) { - if (!recursiveDelete(childIndex)) { - qCritical("failed to delete %s", m_FileSystemModel->fileName(childIndex).toUtf8().constData()); - return false; - } - } else { - if (!m_FileSystemModel->remove(childIndex)) { - qCritical("failed to delete %s", m_FileSystemModel->fileName(childIndex).toUtf8().constData()); - return false; - } + // updateTabs() is also called from onOriginModified() to update all the + // tabs that depend on the origin; if updateTabs() is called because the + // origin changed, but the tab doesn't use origin files, it can be safely + // skipped + // + // this happens for tabs like notes and categories, which don't need to + // be updated when files change + if (becauseOriginChanged && !tabInfo.tab->usesOriginFiles()) { + continue; } - } - if (!m_FileSystemModel->remove(index)) { - qCritical("failed to delete %s", m_FileSystemModel->fileName(index).toUtf8().constData()); - return false; - } - return true; -} - - -void ModInfoDialog::on_openInExplorerButton_clicked() -{ - shell::ExploreFile(m_ModInfo->absolutePath()); -} -void ModInfoDialog::deleteFile(const QModelIndex &index) -{ - bool res = m_FileSystemModel->isDir(index) ? recursiveDelete(index) - : m_FileSystemModel->remove(index); - if (!res) { - QString fileName = m_FileSystemModel->fileName(index); - reportError(tr("Failed to delete %1").arg(fileName)); + // this tab should be updated + interestedTabs.push_back(&tabInfo); } -} - -void ModInfoDialog::delete_activated() -{ - if (ui->fileTree->hasFocus()) { - QItemSelectionModel *selection = ui->fileTree->selectionModel(); - if (selection->hasSelection() && selection->selectedRows().count() >= 1) { - - if (selection->selectedRows().count() == 0) { - return; - } - else if (selection->selectedRows().count() == 1) { - QString fileName = m_FileSystemModel->fileName(selection->selectedRows().at(0)); - if (QMessageBox::question(this, tr("Confirm"), tr("Are you sure you want to delete \"%1\"?").arg(fileName), - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - } - else { - if (QMessageBox::question(this, tr("Confirm"), tr("Are you sure you want to delete the selected files?"), - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - } - - foreach(QModelIndex index, selection->selectedRows()) { - deleteFile(index); - } - } - } -} -void ModInfoDialog::deleteTriggered() -{ - if (m_FileSelection.count() == 0) { - return; - } else if (m_FileSelection.count() == 1) { - QString fileName = m_FileSystemModel->fileName(m_FileSelection.at(0)); - if (QMessageBox::question(this, tr("Confirm"), tr("Are you sure you want to delete \"%1\"?").arg(fileName), - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - } else { - if (QMessageBox::question(this, tr("Confirm"), tr("Are you sure you want to delete the selected files?"), - QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { - return; - } - } + for (auto* tabInfo : interestedTabs) { + // set the current mod + tabInfo->tab->setMod(m_mod, origin); - foreach(QModelIndex index, m_FileSelection) { - deleteFile(index); + // clear + tabInfo->tab->clear(); } -} + // feed all the files from the filesystem + feedFiles(interestedTabs); -void ModInfoDialog::renameTriggered() -{ - QModelIndex selection = m_FileSelection.at(0); - QModelIndex index = selection.sibling(selection.row(), 0); - if (!index.isValid() || m_FileSystemModel->isReadOnly()) { - return; + // call update() on all tabs + for (auto* tabInfo : interestedTabs) { + tabInfo->tab->update(); } - ui->fileTree->edit(index); + // update the text colours + setTabsColors(); } - -void ModInfoDialog::hideTriggered() -{ - changeFiletreeVisibility(false); -} - - -void ModInfoDialog::unhideTriggered() +void ModInfoDialog::feedFiles(std::vector<TabInfo*>& interestedTabs) { - changeFiletreeVisibility(true); -} - -void ModInfoDialog::changeFiletreeVisibility(bool visible) -{ - bool changed = false; - bool stop = false; - - qDebug().nospace() - << (visible ? "unhiding" : "hiding") << " " - << m_FileSelection.size() << " filetree files"; - - QFlags<FileRenamer::RenameFlags> flags = - (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE); - - if (m_FileSelection.size() > 1) { - flags |= FileRenamer::MULTIPLE; + const auto rootPath = m_mod->absolutePath(); + if (rootPath.isEmpty()) { + return; } - FileRenamer renamer(this, flags); - - for (const auto& index : m_FileSelection) { - if (stop) { - break; - } + const fs::path fsPath(rootPath.toStdWString()); - const QString path = m_FileSystemModel->filePath(index); - auto result = FileRenamer::RESULT_CANCEL; - - if (visible) { - if (!canUnhideFile(false, path)) { - qDebug().nospace() << "cannot unhide " << path << ", skipping"; - continue; - } - result = unhideFile(renamer, path); - } else { - if (!canHideFile(false, path)) { - qDebug().nospace() << "cannot hide " << path << ", skipping"; - continue; - } - result = hideFile(renamer, path); + for (const auto& entry : fs::recursive_directory_iterator(fsPath)) { + if (!entry.is_regular_file()) { + // skip directories + continue; } - switch (result) { - case FileRenamer::RESULT_OK: { - // will trigger a refresh at the end - changed = true; - break; - } - - case FileRenamer::RESULT_SKIP: { - // nop - break; - } + const auto filePath = QString::fromStdWString(entry.path().native()); - case FileRenamer::RESULT_CANCEL: { - // stop right now, but make sure to refresh if needed - stop = true; + // for each tab + for (auto* tabInfo : interestedTabs) { + if (tabInfo->tab->feedFile(rootPath, filePath)) { break; } } } - - qDebug().nospace() << (visible ? "unhiding" : "hiding") << " filetree files done"; - - if (changed) { - qDebug().nospace() << "triggering refresh"; - if (m_Origin) { - emit originModified(m_Origin->getID()); - } - refreshLists(); - } } - -void ModInfoDialog::openTriggered() +void ModInfoDialog::setTabsColors() { - if (m_FileSelection.size() == 1) { - const auto index = m_FileSelection.at(0); - if (!index.isValid()) { - return; + const auto p = m_mainWindow->palette(); + + for (const auto& tabInfo : m_tabs) { + if (!tabInfo.isVisible()) { + // don't bother with invisible tabs + continue; } - QString fileName = m_FileSystemModel->filePath(index); - shell::OpenFile(fileName); + const QColor color = tabInfo.tab->hasData() ? + QColor::Invalid : + p.color(QPalette::Disabled, QPalette::WindowText); + + ui->tabWidget->tabBar()->setTabTextColor(tabInfo.realPos, color); } } -void ModInfoDialog::previewTriggered() +void ModInfoDialog::switchToTab(ModInfoTabIDs id) { - if (m_FileSelection.size() == 1) { - const auto index = m_FileSelection.at(0); - if (!index.isValid()) { + // look a tab with the given id + for (const auto& tabInfo : m_tabs) { + if (tabInfo.tab->tabID() == id) { + // use realPos to select the proper tab in the widget + ui->tabWidget->setCurrentIndex(tabInfo.realPos); return; } - - QString fileName = m_FileSystemModel->filePath(index); - m_OrganizerCore->previewFile(this, m_ModInfo->name(), fileName); } + + // this could happen if the tab is not visible right now + qDebug() + << "can't switch to tab ID " << static_cast<int>(id) + << ", not available"; } -void ModInfoDialog::createDirectoryTriggered() +MOShared::FilesOrigin* ModInfoDialog::getOrigin() { - QModelIndex selection = m_FileSelection.at(0); - - QModelIndex index = m_FileSystemModel->isDir(selection) ? selection - : selection.parent(); - index = index.sibling(index.row(), 0); - - QString name = tr("New Folder"); - QString path = m_FileSystemModel->filePath(index).append("/"); + auto* ds = m_core->directoryStructure(); - QModelIndex existingIndex = m_FileSystemModel->index(path + name); - int suffix = 1; - while (existingIndex.isValid()) { - name = tr("New Folder") + QString::number(suffix++); - existingIndex = m_FileSystemModel->index(path + name); + if (!ds->originExists(m_mod->name().toStdWString())) { + return nullptr; } - QModelIndex newIndex = m_FileSystemModel->mkdir(index, name); - if (!newIndex.isValid()) { - reportError(tr("Failed to create \"%1\"").arg(name)); - return; + auto* origin = &ds->getOriginByName(m_mod->name().toStdWString()); + if (origin->isDisabled()) { + return nullptr; } - ui->fileTree->setCurrentIndex(newIndex); - ui->fileTree->edit(newIndex); + return origin; } - -void ModInfoDialog::on_fileTree_customContextMenuRequested(const QPoint &pos) +void ModInfoDialog::saveState(Settings& s) const { - QItemSelectionModel *selectionModel = ui->fileTree->selectionModel(); - m_FileSelection = selectionModel->selectedRows(0); - - QMenu menu(ui->fileTree); - - menu.addAction(m_NewFolderAction); - - if (selectionModel->hasSelection()) { - bool enableOpen = true; - bool enablePreview = true; - bool enableRename = true; - bool enableDelete = true; - bool enableHide = true; - bool enableUnhide = true; - - if (m_FileSelection.size() == 1) { - // single selection - - // only enable open action if a file is selected - bool hasFiles = false; - - foreach(QModelIndex idx, m_FileSelection) { - if (m_FileSystemModel->fileInfo(idx).isFile()) { - hasFiles = true; - break; - } - } - - if (!hasFiles) { - enableOpen = false; - enablePreview = false; - } - - const QString fileName = m_FileSystemModel->fileName(m_FileSelection.at(0)); - - if (!canPreviewFile(m_PluginContainer, false, fileName)) { - enablePreview = false; - } - - if (!canHideFile(false, fileName)) { - enableHide = false; - } - - if (!canUnhideFile(false, fileName)) { - enableUnhide = false; - } - } else { - // this is a multiple selection, don't show open action so users don't open - // a thousand files - enableOpen = false; - enablePreview = false; - enableRename = false; - - if (m_FileSelection.size() < max_scan_for_context_menu) { - // if the number of selected items is low, checking them to accurately - // show the menu items is worth it - enableHide = false; - enableUnhide = false; - - for (const auto& index : m_FileSelection) { - const QString fileName = m_FileSystemModel->fileName(index); - - if (canHideFile(false, fileName)) { - enableHide = true; - } - - if (canUnhideFile(false, fileName)) { - enableUnhide = true; - } - - if (enableHide && enableUnhide) { - // found both, no need to check more - break; - } - } - } - } - - if (enableOpen) { - menu.addAction(m_OpenAction); - } - - if (enablePreview) { - menu.addAction(m_PreviewAction); - } + saveTabOrder(s); - if (enableRename) { - menu.addAction(m_RenameAction); - } - - if (enableDelete) { - menu.addAction(m_DeleteAction); - } + // remove 2.2.0 settings + s.directInterface().remove("mod_info_tabs"); + s.directInterface().remove("mod_info_conflict_expanders"); + s.directInterface().remove("mod_info_conflicts"); + s.directInterface().remove("mod_info_advanced_conflicts"); + s.directInterface().remove("mod_info_conflicts_overwrite"); + s.directInterface().remove("mod_info_conflicts_noconflict"); + s.directInterface().remove("mod_info_conflicts_overwritten"); - if (enableHide) { - menu.addAction(m_HideAction); - } - - if (enableUnhide) { - menu.addAction(m_UnhideAction); - } - } else { - m_FileSelection.clear(); - m_FileSelection.append(m_FileSystemModel->index(m_FileSystemModel->rootPath(), 0)); + // save state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->saveState(s); } - - menu.exec(ui->fileTree->viewport()->mapToGlobal(pos)); } - -void ModInfoDialog::on_categoriesTree_itemChanged(QTreeWidgetItem *item, int) -{ - QTreeWidgetItem *parent = item->parent(); - while ((parent != nullptr) && ((parent->flags() & Qt::ItemIsUserCheckable) != 0) && (parent->checkState(0) == Qt::Unchecked)) { - parent->setCheckState(0, Qt::Checked); - parent = parent->parent(); - } - refreshPrimaryCategoriesBox(); -} - - -void ModInfoDialog::addCheckedCategories(QTreeWidgetItem *tree) +void ModInfoDialog::restoreState(const Settings& s) { - for (int i = 0; i < tree->childCount(); ++i) { - QTreeWidgetItem *child = tree->child(i); - if (child->checkState(0) == Qt::Checked) { - ui->primaryCategoryBox->addItem(child->text(0), child->data(0, Qt::UserRole)); - addCheckedCategories(child); - } - } -} - + // tab order is not restored here, it will be picked up if tabs have to be + // removed and re-added -void ModInfoDialog::refreshPrimaryCategoriesBox() -{ - ui->primaryCategoryBox->clear(); - int primaryCategory = m_ModInfo->getPrimaryCategory(); - addCheckedCategories(ui->categoriesTree->invisibleRootItem()); - for (int i = 0; i < ui->primaryCategoryBox->count(); ++i) { - if (ui->primaryCategoryBox->itemData(i).toInt() == primaryCategory) { - ui->primaryCategoryBox->setCurrentIndex(i); - break; - } + // restore state for each tab + for (const auto& tabInfo : m_tabs) { + tabInfo.tab->restoreState(s); } } - -void ModInfoDialog::on_primaryCategoryBox_currentIndexChanged(int index) +void ModInfoDialog::saveTabOrder(Settings& s) const { - if (index != -1) { - m_ModInfo->setPrimaryCategory(ui->primaryCategoryBox->itemData(index).toInt()); + if (static_cast<int>(m_tabs.size()) != ui->tabWidget->count()) { + // only save tab state when all tabs are visible + // + // if not all tabs are visible, it becomes very difficult to figure out in + // what order the user wants these tabs to be, so just avoid saving it + // completely + // + // this means that reordering tabs when not all tabs are visible is not + // saved, but it's better than breaking everything + return; } -} + QString names; -void ModInfoDialog::on_overwriteTree_itemDoubleClicked(QTreeWidgetItem *item, int) -{ - if (auto* ci=dynamic_cast<ConflictItem*>(item)) { - const auto origin = ci->altOrigin(); - - if (!origin.isEmpty()) { - close(); - emit modOpen(origin, TAB_CONFLICTS); + for (int i=0; i<ui->tabWidget->count(); ++i) { + if (!names.isEmpty()) { + names += " "; } - } -} -FileRenamer::RenameResults ModInfoDialog::hideFile(FileRenamer& renamer, const QString &oldName) -{ - const QString newName = oldName + ModInfo::s_HiddenExt; - return renamer.rename(oldName, newName); -} + names += ui->tabWidget->widget(i)->objectName(); + } -FileRenamer::RenameResults ModInfoDialog::unhideFile(FileRenamer& renamer, const QString &oldName) -{ - QString newName = oldName.left(oldName.length() - ModInfo::s_HiddenExt.length()); - return renamer.rename(oldName, newName); + s.directInterface().setValue("mod_info_tab_order", names); } -void ModInfoDialog::changeConflictItemsVisibility( - const QList<QTreeWidgetItem*>& items, bool visible) +std::vector<QString> ModInfoDialog::getOrderedTabNames() const { - bool changed = false; - bool stop = false; - - qDebug().nospace() - << (visible ? "unhiding" : "hiding") << " " - << items.size() << " conflict files"; + const auto& settings = Settings::instance().directInterface(); - QFlags<FileRenamer::RenameFlags> flags = - (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE); + std::vector<QString> v; - if (items.size() > 1) { - flags |= FileRenamer::MULTIPLE; - } - - FileRenamer renamer(this, flags); - - for (const auto* item : items) { - if (stop) { - break; - } - - const auto* ci = dynamic_cast<const ConflictItem*>(item); - if (!ci) { - continue; - } - - auto result = FileRenamer::RESULT_CANCEL; + if (settings.contains("mod_info_tabs")) { + // old byte array from 2.2.0 + QDataStream stream(settings.value("mod_info_tabs").toByteArray()); - if (visible) { - if (!ci->canUnhide()) { - qDebug().nospace() << "cannot unhide " << item->text(0) << ", skipping"; - continue; - } - result = unhideFile(renamer, ci->fileName()); + int count = 0; + stream >> count; - } else { - if (!ci->canHide()) { - qDebug().nospace() << "cannot hide " << item->text(0) << ", skipping"; - continue; - } - result = hideFile(renamer, ci->fileName()); + for (int i=0; i<count; ++i) { + QString s; + stream >> s; + v.emplace_back(std::move(s)); } + } else { + // string list + QString string = settings.value("mod_info_tab_order").toString(); + QTextStream stream(&string); - switch (result) { - case FileRenamer::RESULT_OK: { - // will trigger a refresh at the end - changed = true; - break; - } - - case FileRenamer::RESULT_SKIP: { - // nop - break; - } - - case FileRenamer::RESULT_CANCEL: { - // stop right now, but make sure to refresh if needed - stop = true; - break; - } - } - } - - qDebug().nospace() << (visible ? "unhiding" : "hiding") << " conflict files done"; - - if (changed) { - qDebug().nospace() << "triggering refresh"; - if (m_Origin) { - emit originModified(m_Origin->getID()); + while (!stream.atEnd()) { + QString s; + stream >> s; + v.emplace_back(std::move(s)); } - refreshLists(); } -} -void ModInfoDialog::openConflictItems(const QList<QTreeWidgetItem*>& items) -{ - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - for (auto* item : items) { - if (auto* ci=dynamic_cast<ConflictItem*>(item)) { - m_OrganizerCore->executeFileVirtualized(this, ci->fileName()); - } - } + return v; } -void ModInfoDialog::previewConflictItems(const QList<QTreeWidgetItem*>& items) +void ModInfoDialog::onOriginModified(int originID) { - // the menu item is only shown for a single selection, but handle all of them - // in case this changes - for (auto* item : items) { - if (auto* ci=dynamic_cast<ConflictItem*>(item)) { - m_OrganizerCore->previewFileWithAlternatives(this, ci->fileName()); - } - } -} + // tell the main window the origin changed + emit originModified(originID); -void ModInfoDialog::on_overwriteTree_customContextMenuRequested(const QPoint &pos) -{ - showConflictMenu(pos, ui->overwriteTree); + // update tabs that depend on the origin + updateTabs(true); } -void ModInfoDialog::on_overwrittenTree_customContextMenuRequested(const QPoint &pos) +void ModInfoDialog::onDeleteShortcut() { - showConflictMenu(pos, ui->overwrittenTree); + // forward the request to the current tab + if (auto* tabInfo=currentTab()) { + tabInfo->tab->deleteRequested(); + } } -void ModInfoDialog::on_noConflictTree_customContextMenuRequested(const QPoint &pos) +void ModInfoDialog::closeEvent(QCloseEvent* e) { - showConflictMenu(pos, ui->noConflictTree); + if (tryClose()) { + e->accept(); + } else { + e->ignore(); + } } -void ModInfoDialog::on_conflictsAdvancedList_customContextMenuRequested(const QPoint &pos) +void ModInfoDialog::onCloseButton() { - showConflictMenu(pos, ui->conflictsAdvancedList); + if (tryClose()) { + close(); + } } -void ModInfoDialog::showConflictMenu(const QPoint &pos, QTreeWidget* tree) +bool ModInfoDialog::tryClose() { - auto actions = createConflictMenuActions(tree->selectedItems()); - - QMenu menu; - - // open - if (actions.open) { - connect(actions.open, &QAction::triggered, [&]{ - openConflictItems(tree->selectedItems()); - }); - - menu.addAction(actions.open); - } - - // preview - if (actions.preview) { - connect(actions.preview, &QAction::triggered, [&]{ - previewConflictItems(tree->selectedItems()); - }); - - menu.addAction(actions.preview); - } - - // hide - if (actions.hide) { - connect(actions.hide, &QAction::triggered, [&]{ - changeConflictItemsVisibility(tree->selectedItems(), false); - }); - - menu.addAction(actions.hide); - } - - // unhide - if (actions.unhide) { - connect(actions.unhide, &QAction::triggered, [&]{ - changeConflictItemsVisibility(tree->selectedItems(), true); - }); - - menu.addAction(actions.unhide); - } - - // goto - if (actions.gotoMenu) { - menu.addMenu(actions.gotoMenu); - - for (auto* a : actions.gotoActions) { - connect(a, &QAction::triggered, [&, name=a->text()]{ - close(); - emit modOpen(name, TAB_CONFLICTS); - }); + // cancel the close if any tab returns false; for example. this can happen if + // a tab has unsaved content, pops a confirmation dialog, and the user clicks + // cancel - actions.gotoMenu->addAction(a); + for (auto& tabInfo : m_tabs) { + if (!tabInfo.tab->canClose()) { + return false; } } - if (!menu.isEmpty()) { - menu.exec(tree->viewport()->mapToGlobal(pos)); - } + return true; } -ModInfoDialog::ConflictActions ModInfoDialog::createConflictMenuActions( - const QList<QTreeWidgetItem*>& selection) +void ModInfoDialog::onTabSelectionChanged() { - if (selection.empty()) { - return {}; - } - - bool enableHide = true; - bool enableUnhide = true; - bool enableOpen = true; - bool enablePreview = true; - bool enableGoto = true; - - if (selection.size() == 1) { - // this is a single selection - const auto* ci = dynamic_cast<const ConflictItem*>(selection[0]); - if (!ci) { - return {}; - } - - enableHide = ci->canHide(); - enableUnhide = ci->canUnhide(); - enableOpen = ci->canOpen(); - enablePreview = ci->canPreview(m_PluginContainer); - enableGoto = ci->hasAlts(); - } - else { - // this is a multiple selection, don't show open/preview so users don't open - // a thousand files - enableOpen = false; - enablePreview = false; - - // don't bother with this on multiple selection, at least for now - enableGoto = false; - - if (selection.size() < max_scan_for_context_menu) { - // if the number of selected items is low, checking them to accurately - // show the menu items is worth it - enableHide = false; - enableUnhide = false; - - for (const auto* item : selection) { - if (const auto* ci=dynamic_cast<const ConflictItem*>(item)) { - if (ci->canHide()) { - enableHide = true; - } - - if (ci->canUnhide()) { - enableUnhide = true; - } - - if (enableHide && enableUnhide && enableGoto) { - // found all, no need to check more - break; - } - } - } - } + if (m_arrangingTabs) { + // this can be fired while re-arranging tabs, which happens before mods + // are given to tabs, and might trigger first activation, which breaks all + // sorts of things + return; } - ConflictActions actions; - - actions.hide = new QAction(tr("Hide"), this); - actions.hide->setEnabled(enableHide); - - // note that it is possible for hidden files to appear if they override other - // hidden files from another mod - actions.unhide = new QAction(tr("Unhide"), this); - actions.unhide->setEnabled(enableUnhide); - - actions.open = new QAction(tr("Open/Execute"), this); - actions.open->setEnabled(enableOpen); - - actions.preview = new QAction(tr("Preview"), this); - actions.preview->setEnabled(enablePreview); - - actions.gotoMenu = new QMenu(tr("Go to..."), this); - actions.gotoMenu->setEnabled(enableGoto); - - if (enableGoto) { - actions.gotoActions = createGotoActions(selection); + // this will call firstActivation() on the tab if needed + if (auto* tabInfo=currentTab()) { + tabInfo->tab->activated(); } - - return actions; } -std::vector<QAction*> ModInfoDialog::createGotoActions(const QList<QTreeWidgetItem*>& selection) +void ModInfoDialog::onTabMoved() { - if (!m_Origin || selection.size() != 1) { - return {}; + // reset + for (auto& tabInfo : m_tabs) { + tabInfo.realPos = -1; } - const auto* item = dynamic_cast<const ConflictItem*>(selection[0]); - if (!item) { - return {}; - } + // for each tab in the widget + for (int i=0; i<ui->tabWidget->count(); ++i) { + const auto* w = ui->tabWidget->widget(i); - auto file = m_Origin->findFile(item->fileIndex()); - if (!file) { - return {}; - } + bool found = false; - - std::vector<QString> mods; - - // add all alternatives - for (const auto& alt : file->getAlternatives()) { - const auto& o = m_Directory->getOriginByID(alt.first); - if (o.getID() != m_Origin->getID()) { - mods.push_back(ToQString(o.getName())); + // find the corresponding tab info + for (auto& tabInfo : m_tabs) { + if (tabInfo.widget == w) { + tabInfo.realPos = i; + found = true; + break; + } } - } - - // add the real origin if different from this mod - const FilesOrigin& realOrigin = m_Directory->getOriginByID(file->getOrigin()); - if (realOrigin.getID() != m_Origin->getID()) { - mods.push_back(ToQString(realOrigin.getName())); - } - - std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { - return (QString::localeAwareCompare(a, b) < 0); - }); - - std::vector<QAction*> actions; - - for (const auto& name : mods) { - actions.push_back(new QAction(name, this)); - } - return actions; -} - -void ModInfoDialog::on_overwrittenTree_itemDoubleClicked(QTreeWidgetItem *item, int) -{ - if (const auto* ci=dynamic_cast<ConflictItem*>(item)) { - const auto origin = ci->altOrigin(); - - if (!origin.isEmpty()) { - close(); - emit modOpen(origin, TAB_CONFLICTS); + if (!found) { + qCritical() << "unknown tab at index " << i; } } } -void ModInfoDialog::on_refreshButton_clicked() -{ - if (m_ModInfo->getNexusID() > 0) { - QLineEdit *modIDEdit = findChild<QLineEdit*>("modIDEdit"); - int modID = modIDEdit->text().toInt(); - refreshNexusData(modID); - } else - qInfo("Mod has no valid Nexus ID, info can't be updated."); -} - -void ModInfoDialog::on_endorseBtn_clicked() -{ - emit endorseMod(m_ModInfo); -} - -void ModInfoDialog::on_nextButton_clicked() -{ - int currentTab = ui->tabWidget->currentIndex(); - int tab = m_RealTabPos[currentTab]; - - emit modOpenNext(tab); - this->accept(); -} - -void ModInfoDialog::on_prevButton_clicked() +void ModInfoDialog::onNextMod() { - int currentTab = ui->tabWidget->currentIndex(); - int tab = m_RealTabPos[currentTab]; + auto mod = m_mainWindow->nextModInList(); + if (!mod || mod == m_mod) { + return; + } - emit modOpenPrev(tab); - this->accept(); + setMod(mod); + update(); } - -void ModInfoDialog::createTweak() +void ModInfoDialog::onPreviousMod() { - QString name = QInputDialog::getText(this, tr("Name"), tr("Please enter a name")); - if (name.isNull()) { - return; - } else if (!fixDirectoryName(name)) { - QMessageBox::critical(this, tr("Error"), tr("Invalid name. Must be a valid file name")); - return; - } else if (ui->iniTweaksList->findItems(name, Qt::MatchFixedString).count() != 0) { - QMessageBox::critical(this, tr("Error"), tr("A tweak by that name exists")); + auto mod = m_mainWindow->previousModInList(); + if (!mod || mod == m_mod) { return; } - QListWidgetItem *newTweak = new QListWidgetItem(name + ".ini"); - newTweak->setData(Qt::UserRole, "INI Tweaks/" + name + ".ini"); - newTweak->setFlags(newTweak->flags() | Qt::ItemIsUserCheckable); - newTweak->setCheckState(Qt::Unchecked); - ui->iniTweaksList->addItem(newTweak); -} - -void ModInfoDialog::on_iniTweaksList_customContextMenuRequested(const QPoint &pos) -{ - QMenu menu; - menu.addAction(tr("Create Tweak"), this, SLOT(createTweak())); - menu.exec(ui->iniTweaksList->mapToGlobal(pos)); + setMod(mod); + update(); } diff --git a/src/modinfodialog.h b/src/modinfodialog.h index c4f65d8a..34555b0c 100644 --- a/src/modinfodialog.h +++ b/src/modinfodialog.h @@ -23,463 +23,236 @@ along with Mod Organizer. If not, see <http://www.gnu.org/licenses/>. #include "modinfo.h"
#include "tutorabledialog.h"
-#include "plugincontainer.h"
-#include "organizercore.h"
-#include "filterwidget.h"
+#include "filerenamer.h"
+#include "modinfodialogfwd.h"
-#include <QDialog>
-#include <QSignalMapper>
-#include <QSettings>
-#include <QNetworkAccessManager>
-#include <QNetworkReply>
-#include <QModelIndex>
-#include <QAction>
-#include <QListWidgetItem>
-#include <QTreeWidgetItem>
-#include <QTextCodec>
-#include <set>
-#include <directoryentry.h>
+namespace Ui { class ModInfoDialog; }
+namespace MOShared { class FilesOrigin; }
-
-namespace Ui {
- class ModInfoDialog;
-}
-
-class QFileSystemModel;
-class QTreeView;
-class CategoryFactory;
+class PluginContainer;
+class OrganizerCore;
+class Settings;
+class ModInfoDialogTab;
+class MainWindow;
/**
-* Renames individual files and handles dialog boxes to confirm replacements and
-* failures with the user
-**/
-class FileRenamer
-{
-public:
- /**
- * controls appearance and replacement behaviour; if RENAME_REPLACE_ALL and
- * RENAME_REPLACE_NONE are not provided, the user will have the option to
- * choose on the first replacement
- **/
- enum RenameFlags
- {
- /**
- * this renamer will be used on multiple files, so display additional
- * buttons to replace all and for canceling
- **/
- MULTIPLE = 0x01,
-
- /**
- * customizes some of the text shown on dialog to mention that files are
- * being hidden
- **/
- HIDE = 0x02,
-
- /**
- * customizes some of the text shown on dialog to mention that files are
- * being unhidden
- **/
- UNHIDE = 0x04,
-
- /**
- * silently replaces all existing files
- **/
- REPLACE_ALL = 0x08,
-
- /**
- * silently skips all existing files
- **/
- REPLACE_NONE = 0x10,
- };
-
-
- /** result of a single rename
- *
- **/
- enum RenameResults
- {
- /**
- * the user skipped this file
- */
- RESULT_SKIP,
-
- /**
- * the file was successfully renamed
- */
- RESULT_OK,
-
- /**
- * the user wants to cancel
- */
- RESULT_CANCEL
- };
-
-
- /**
- * @param parent Parent widget for dialog boxes
- **/
- FileRenamer(QWidget* parent, QFlags<RenameFlags> flags);
-
- /**
- * renames the given file
- * @param oldName current filename
- * @param newName new filename
- * @return whether the file was renamed, skipped or the user wants to cancel
- **/
- RenameResults rename(const QString& oldName, const QString& newName);
-
-private:
- /**
- *user's decision when replacing
- **/
- enum RenameDecision
- {
- /**
- * replace the file
- **/
- DECISION_REPLACE,
-
- /**
- * skip the file
- **/
- DECISION_SKIP,
-
- /**
- * cancel the whole thing
- **/
- DECISION_CANCEL
- };
-
- /**
- * parent widget for dialog boxes
- **/
- QWidget* m_parent;
-
- /**
- * flags
- **/
- QFlags<RenameFlags> m_flags;
-
- /**
- * asks the user to replace an existing file, may return early if the user
- * has already selected to replace all/none
- * @return whether to replace, skip or cancel
- **/
- RenameDecision confirmReplace(const QString& newName);
-
- /**
- * removal of a file failed, ask the user to continue or cancel
- * @param name The name of the file that failed to be removed
- * @return true to continue, false to stop
- **/
- bool removeFailed(const QString& name);
-
- /**
- * renaming a file failed, ask the user to continue or cancel
- * @param oldName current filename
- * @param newName new filename
- * @return true to continue, false to stop
- **/
- bool renameFailed(const QString& oldName, const QString& newName);
-};
-
-
-/* Takes a QToolButton and a widget and creates an expandable widget.
- **/
-class ExpanderWidget
-{
-public:
- /** empty expander, use set()
- **/
- ExpanderWidget();
-
- /** see set()
- **/
- ExpanderWidget(QToolButton* button, QWidget* content);
-
- /** @brief sets the button and content widgets to use
- * the button will be given an arrow icon, clicking it will toggle the
- * visibility of the given widget
- * @param button the button that toggles the content
- * @param content the widget that will be shown or hidden
- * @param opened initial state, defaults to closed
- **/
- void set(QToolButton* button, QWidget* content, bool opened=false);
-
- /** either opens or closes the expander depending on the current state
- **/
- void toggle();
-
- /** sets the current state of the expander
- **/
- void toggle(bool b);
-
- /** returns whether the expander is currently opened
- **/
- bool opened() const;
-
-private:
- QToolButton* m_button;
- QWidget* m_content;
- bool opened_;
-};
-
-
-/**
- * this is a larger dialog used to visualise information abount the mod.
+ * this is a larger dialog used to visualise information about the mod.
* @todo this would probably a good place for a plugin-system
**/
class ModInfoDialog : public MOBase::TutorableDialog
{
- Q_OBJECT
+ Q_OBJECT;
-public:
-
- enum ETabs {
- TAB_TEXTFILES,
- TAB_INIFILES,
- TAB_IMAGES,
- TAB_ESPS,
- TAB_CONFLICTS,
- TAB_CATEGORIES,
- TAB_NEXUS,
- TAB_NOTES,
- TAB_FILETREE
- };
+ // creates a tab, it's a friend because it uses a bunch of member variables
+ // to create ModInfoDialogTabContext
+ //
+ template <class T>
+ friend std::unique_ptr<ModInfoDialogTab> createTab(
+ ModInfoDialog& d, ModInfoTabIDs index);
public:
-
- /**
- * @brief constructor
- *
- * @param modInfo info structure about the mod to display
- * @param parent parend widget
- **/
- explicit ModInfoDialog(ModInfo::Ptr modInfo, const MOShared::DirectoryEntry *directory, bool unmanaged, OrganizerCore *organizerCore, PluginContainer *pluginContainer, QWidget *parent = 0);
+ ModInfoDialog(
+ MainWindow* mw, OrganizerCore* core, PluginContainer* plugin,
+ ModInfo::Ptr mod);
~ModInfoDialog();
- /**
- * @brief retrieve the (user-modified) version of the mod
- *
- * @return the (user-modified) version of the mod
- **/
- QString getModVersion() const;
-
- /**
- * @brief retrieve the (user-modified) mod id
- *
- * @return the (user-modified) id of the mod
- **/
- const int getModID() const;
-
- /**
- * @brief open the specified tab in the dialog if it's enabled
- *
- * @param tab the tab to activate
- **/
- void openTab(int tab);
+ // switches to the tab with the given id
+ //
+ void selectTab(ModInfoTabIDs id);
+ // updates all tabs, selects the initial tab and opens the dialog
+ //
int exec() override;
+ // saves the dialog state and calls saveState() on all tabs
+ //
void saveState(Settings& s) const;
+
+ // restores the dialog state and calls restoreState() on all tabs
+ //
void restoreState(const Settings& s);
signals:
-
- void thumbnailClickedSignal(const QString &filename);
- void linkActivated(const QString &link);
- void downloadRequest(const QString &link);
- void modOpen(const QString &modName, int tab);
- void modOpenNext(int tab=-1);
- void modOpenPrev(int tab=-1);
+ // emitted when a tab changes the origin
+ //
void originModified(int originID);
- void endorseMod(ModInfo::Ptr nexusID);
-
-public slots:
- void modDetailsUpdated(bool success);
+protected:
+ // forwards to tryClose()
+ //
+ void closeEvent(QCloseEvent* e);
private:
+ // represents a single tab
+ //
+ struct TabInfo
+ {
+ // tab implementation
+ std::unique_ptr<ModInfoDialogTab> tab;
- void initFiletree(ModInfo::Ptr modInfo);
- void initINITweaks();
-
- void refreshLists();
+ // actual position in the tab bar, updated every time a tab is moved
+ int realPos;
- void addCategories(const CategoryFactory &factory, const std::set<int> &enabledCategories, QTreeWidgetItem *root, int rootLevel);
+ // widget used by the QTabWidget for this tab
+ //
+ // because QTabWidget doesn't support simply hiding tabs, they have to be
+ // completely removed from the widget when they don't support the current
+ // mod
+ //
+ // therefore, `widget, `caption` and `icon` are remembered so tabs can be
+ // removed and re-added when navigating between mods
+ //
+ // `widget` is also used figure out which tab is where when they're
+ // re-ordered
+ QWidget* widget;
- void updateVersionColor();
+ // caption for this tab, see `widget`
+ QString caption;
- void refreshNexusData(int modID);
- void activateNexusTab();
- QString getFileCategory(int categoryID);
- bool recursiveDelete(const QModelIndex &index);
- void deleteFile(const QModelIndex &index);
- void saveIniTweaks();
- void saveCategories(QTreeWidgetItem *currentNode);
- void saveCurrentTextFile();
- void saveCurrentIniFile();
- void openTextFile(const QString &fileName);
- void openIniFile(const QString &fileName);
- bool allowNavigateFromTXT();
- bool allowNavigateFromINI();
- FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString &oldName);
- FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString &oldName);
- void addCheckedCategories(QTreeWidgetItem *tree);
- void refreshPrimaryCategoriesBox();
+ // icon for this tab, see `widget`
+ QIcon icon;
- int tabIndex(const QString &tabId);
-private slots:
- void thumbnailClicked(const QString &fileName);
- void linkClicked(const QUrl &url);
- void linkClicked(QString url);
+ TabInfo(std::unique_ptr<ModInfoDialogTab> tab);
- void delete_activated();
+ // returns whether this tab is part of the tab widget
+ //
+ bool isVisible() const;
+ };
- void createDirectoryTriggered();
- void openTriggered();
- void previewTriggered();
- void renameTriggered();
- void deleteTriggered();
- void hideTriggered();
- void unhideTriggered();
+ std::unique_ptr<Ui::ModInfoDialog> ui;
+ MainWindow* m_mainWindow;
+ ModInfo::Ptr m_mod;
+ OrganizerCore* m_core;
+ PluginContainer* m_plugin;
+ std::vector<TabInfo> m_tabs;
- void on_openInExplorerButton_clicked();
- void on_closeButton_clicked();
- void on_saveButton_clicked();
- void on_activateESP_clicked();
- void on_deactivateESP_clicked();
- void on_saveTXTButton_clicked();
- void on_visitNexusLabel_linkActivated(const QString &link);
- void on_modIDEdit_editingFinished();
- void on_sourceGameEdit_currentIndexChanged(int);
- void on_versionEdit_editingFinished();
- void on_customUrlLineEdit_editingFinished();
- void on_iniFileView_textChanged();
- void on_textFileView_textChanged();
- void on_tabWidget_currentChanged(int index);
- void on_primaryCategoryBox_currentIndexChanged(int index);
- void on_categoriesTree_itemChanged(QTreeWidgetItem *item, int column);
- void on_textFileList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous);
- void on_iniFileList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous);
- void on_iniTweaksList_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous);
- void on_overwriteTree_itemDoubleClicked(QTreeWidgetItem *item, int column);
- void on_overwrittenTree_itemDoubleClicked(QTreeWidgetItem *item, int column);
- void on_overwriteTree_customContextMenuRequested(const QPoint &pos);
- void on_overwrittenTree_customContextMenuRequested(const QPoint &pos);
- void on_noConflictTree_customContextMenuRequested(const QPoint &pos);
- void on_conflictsAdvancedList_customContextMenuRequested(const QPoint &pos);
- void on_fileTree_customContextMenuRequested(const QPoint &pos);
+ // initial tab requested by the main window when the dialog is opened; whether
+ // the request can be honoured depends on what tabs are present
+ ModInfoTabIDs m_initialTab;
- void on_refreshButton_clicked();
+ // set to true when tabs are being removed and re-added while navigating
+ // between mods; since the current index changes while this is happening,
+ // onTabSelectionChanged() will be called repeatedly
+ //
+ // however, it will check this flag and ignore the event so first activations
+ // are not fired incorrectly
+ bool m_arrangingTabs;
- void on_endorseBtn_clicked();
- void on_nextButton_clicked();
+ // creates all the tabs and connects events
+ //
+ void createTabs();
- void on_prevButton_clicked();
- void on_iniTweaksList_customContextMenuRequested(const QPoint &pos);
+ // sets the currently selected mod; resets first activation, but doesn't
+ // update anything
+ //
+ void setMod(ModInfo::Ptr mod);
- void createTweak();
-private:
- using FileEntry = MOShared::FileEntry;
+ // sets the currently selected mod, if found; forwards to setMod() above
+ //
+ void setMod(const QString& name);
- struct ConflictActions
- {
- QAction* hide;
- QAction* unhide;
- QAction* open;
- QAction* preview;
- QMenu* gotoMenu;
- std::vector<QAction*> gotoActions;
-
- ConflictActions() :
- hide(nullptr), unhide(nullptr), open(nullptr), preview(nullptr),
- gotoMenu(nullptr)
- {
- }
- };
+ // returns the origin of the current mod, may be null
+ //
+ MOShared::FilesOrigin* getOrigin();
- Ui::ModInfoDialog *ui;
- ModInfo::Ptr m_ModInfo;
+ // returns the currently selected tab, taking re-ordering in to account;
+ // shouldn't be null, but could be
+ //
+ TabInfo* currentTab();
- QSignalMapper m_ThumbnailMapper;
- QString m_RootPath;
- OrganizerCore *m_OrganizerCore;
- PluginContainer *m_PluginContainer;
+ // fully updates the dialog; sets the title, the tab visibility and updates
+ // all the tabs; used when the current mod changes
+ //
+ // see setTabsVisibility() for firstTime
+ //
+ void update(bool firstTime=false);
- QFileSystemModel *m_FileSystemModel;
- QTreeView *m_FileTree;
- QModelIndexList m_FileSelection;
+ // builds the list of visible tabs; if the list is different from what's
+ // currently displayed, or firstTime is true, forwards to reAddTabs()
+ void setTabsVisibility(bool firstTime);
- QSettings *m_Settings;
+ // clears the tab widgets and re-adds the tabs having the visible flag in
+ // the given vector, following the tab order from the settings
+ //
+ void reAddTabs(const std::vector<bool>& visibility, ModInfoTabIDs sel);
- std::set<int> m_RequestIDs;
- bool m_RequestStarted;
+ // called by update(); clears tabs, feeds files and calls update() on all
+ // tabs, then setTabsColors()
+ //
+ void updateTabs(bool becauseOriginChanged=false);
- QAction *m_NewFolderAction;
- QAction *m_OpenAction;
- QAction *m_PreviewAction;
- QAction *m_RenameAction;
- QAction *m_DeleteAction;
- QAction *m_HideAction;
- QAction *m_UnhideAction;
+ // goes through all files on the filesystem for the current mod and calls
+ // feedFile() on every tab until one accepts it
+ //
+ void feedFiles(std::vector<TabInfo*>& interestedTabs);
- const MOShared::DirectoryEntry *m_Directory;
- MOShared::FilesOrigin *m_Origin;
+ // goes through all tabs and sets the tab text colour depending on whether
+ // they have data or not
+ //
+ void setTabsColors();
- std::map<int, int> m_RealTabPos;
- ExpanderWidget m_overwriteExpander, m_overwrittenExpander, m_nonconflictExpander;
- FilterWidget m_advancedConflictFilter;
+ // called when the delete key is pressed anywhere in the dialog; forwards to
+ // ModInfoDialogTab::deleteRequest() for the currently selected tab
+ //
+ void onDeleteShortcut();
- void refreshConflictLists(bool refreshGeneral, bool refreshAdvanced);
- void refreshFiles();
+ // finds the tab with the given id and selects it
+ //
+ void switchToTab(ModInfoTabIDs id);
- QTreeWidgetItem* createOverwriteItem(
- FileEntry::Index index, bool archive,
- const QString& fileName, const QString& relativeName,
- const MOShared::FileEntry::AlternativesVector& alternatives);
- QTreeWidgetItem* createNoConflictItem(
- FileEntry::Index index, bool archive,
- const QString& fileName, const QString& relativeName);
+ // saves the current tab order; used by saveState(), but also by
+ // setTabsVisibility() to make sure any changes to order are saved before
+ // re-adding tabs
+ //
+ void saveTabOrder(Settings& s) const;
- QTreeWidgetItem* createOverwrittenItem(
- FileEntry::Index index, int fileOrigin, bool archive,
- const QString& fileName, const QString& relativeName);
+ // returns a list of tab names in the order they should appear on the widget
+ //
+ std::vector<QString> getOrderedTabNames() const;
- QTreeWidgetItem* createAdvancedConflictItem(
- FileEntry::Index index, int fileOrigin, bool archive,
- const QString& fileName, const QString& relativeName,
- const MOShared::FileEntry::AlternativesVector& alternatives);
+ // asks all the tabs if they accept closing the dialog, returns false if one
+ // objected
+ //
+ bool tryClose();
- void restoreTabState(const QByteArray &state);
- void restoreConflictsState(const QByteArray &state);
- QByteArray saveTabState() const;
- QByteArray saveConflictsState() const;
+ // called when the user clicks the close button; closing the dialog by other
+ // means ends up in closeEvent(); forwards to tryClose()
+ //
+ void onCloseButton();
- void changeFiletreeVisibility(bool visible);
+ // called when the user clicks the previous button; asks the main window for
+ // the previous mod in the list and loads it
+ //
+ void onPreviousMod();
- void openConflictItems(const QList<QTreeWidgetItem*>& items);
- void previewConflictItems(const QList<QTreeWidgetItem*>& items);
- void changeConflictItemsVisibility(
- const QList<QTreeWidgetItem*>& items, bool visible);
+ // called when the user clicks the next button; asks the main window for the
+ // next mod in the list and loads it
+ //
+ void onNextMod();
- void showConflictMenu(const QPoint &pos, QTreeWidget* tree);
+ // called when the selects a tab; handles first activation
+ //
+ void onTabSelectionChanged();
- ConflictActions createConflictMenuActions(
- const QList<QTreeWidgetItem*>& selection);
+ // called when the user re-orders tabs; sets the correct TabInfo::realPos for
+ // all tabs
+ //
+ void onTabMoved();
- std::vector<QAction*> createGotoActions(
- const QList<QTreeWidgetItem*>& selection);
+ // called when a tab has modified the origin; emits originModified() and
+ // updates all the tabs that use origin files
+ //
+ void onOriginModified(int originID);
};
#endif // MODINFODIALOG_H
diff --git a/src/modinfodialog.ui b/src/modinfodialog.ui index eed4e31f..5b559992 100644 --- a/src/modinfodialog.ui +++ b/src/modinfodialog.ui @@ -6,7 +6,7 @@ <rect>
<x>0</x>
<y>0</y>
- <width>790</width>
+ <width>735</width>
<height>534</height>
</rect>
</property>
@@ -27,162 +27,134 @@ </property>
<widget class="QWidget" name="tabText">
<attribute name="title">
- <string>Textfiles</string>
+ <string>Text Files</string>
</attribute>
- <layout class="QHBoxLayout" name="horizontalLayout_3">
+ <layout class="QVBoxLayout" name="verticalLayout_15">
<item>
- <widget class="QListWidget" name="textFileList">
- <property name="maximumSize">
- <size>
- <width>192</width>
- <height>16777215</height>
- </size>
- </property>
- <property name="toolTip">
- <string>A list of text-files in the mod directory.</string>
+ <widget class="QSplitter" name="tabTextSplitter">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
</property>
- <property name="whatsThis">
- <string>A list of text-files in the mod directory like readmes. </string>
+ <property name="childrenCollapsible">
+ <bool>false</bool>
</property>
- </widget>
- </item>
- <item>
- <layout class="QVBoxLayout" name="verticalLayout_9">
- <item>
- <widget class="QTextEdit" name="textFileView">
- <property name="toolTip">
- <string/>
+ <widget class="QWidget" name="widget_6" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_16">
+ <property name="leftMargin">
+ <number>0</number>
</property>
- <property name="whatsThis">
- <string/>
+ <property name="topMargin">
+ <number>0</number>
</property>
- </widget>
- </item>
- <item>
- <widget class="QPushButton" name="saveTXTButton">
- <property name="enabled">
- <bool>false</bool>
+ <property name="rightMargin">
+ <number>0</number>
</property>
- <property name="text">
- <string>Save</string>
+ <property name="bottomMargin">
+ <number>0</number>
</property>
- </widget>
- </item>
- </layout>
+ <item>
+ <widget class="QLabel" name="label_5">
+ <property name="text">
+ <string>Text Files</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListView" name="textFileList">
+ <property name="toolTip">
+ <string>A list of text-files in the mod directory.</string>
+ </property>
+ <property name="whatsThis">
+ <string>A list of text-files in the mod directory like readmes. </string>
+ </property>
+ <property name="uniformItemSizes">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="textFileFilter"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="widget_7" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_17">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="TextEditor" name="textFileEditor"/>
+ </item>
+ </layout>
+ </widget>
+ </widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tabIni">
<attribute name="title">
- <string>INI-Files</string>
+ <string>INI Files</string>
</attribute>
- <layout class="QHBoxLayout" name="horizontalLayout_4">
+ <layout class="QVBoxLayout" name="verticalLayout_9">
<item>
- <layout class="QVBoxLayout" name="verticalLayout_5" stretch="0,0,0,0">
- <property name="spacing">
- <number>6</number>
- </property>
- <property name="sizeConstraint">
- <enum>QLayout::SetMinimumSize</enum>
+ <widget class="QSplitter" name="tabIniSplitter">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
</property>
- <item>
- <widget class="QLabel" name="label_6">
- <property name="text">
- <string>Ini Files</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QListWidget" name="iniFileList">
- <property name="maximumSize">
- <size>
- <width>228</width>
- <height>16777215</height>
- </size>
- </property>
- <property name="toolTip">
- <string>This is a list of .ini files in the mod.</string>
- </property>
- <property name="whatsThis">
- <string>This is a list of .ini files in the mod. These are usually used to configure the behaviour of mods if there are configurable parameters.</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QLabel" name="label_5">
- <property name="enabled">
- <bool>false</bool>
- </property>
- <property name="text">
- <string>Ini Tweaks *This feature is non-functional*</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QListWidget" name="iniTweaksList">
- <property name="enabled">
- <bool>false</bool>
- </property>
- <property name="maximumSize">
- <size>
- <width>228</width>
- <height>16777215</height>
- </size>
- </property>
- <property name="contextMenuPolicy">
- <enum>Qt::CustomContextMenu</enum>
- </property>
- <property name="toolTip">
- <string>This is a list of ini tweaks (ini modifications that can be toggled).</string>
- </property>
- <property name="whatsThis">
- <string>This is a list of ini tweaks. Ini Tweaks are (usually small) fragments of ini files that are applied over existing settings in skyrim.ini/skyrimprefs.ini. Each tweak can be toggled individually. You should check the description of the mod whether the tweaks are really optional.</string>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QVBoxLayout" name="verticalLayout_4" stretch="0,0">
- <item>
- <widget class="QTextBrowser" name="iniFileView">
- <property name="toolTip">
- <string/>
- </property>
- <property name="whatsThis">
- <string/>
- </property>
- <property name="undoRedoEnabled">
- <bool>true</bool>
- </property>
- <property name="readOnly">
- <bool>false</bool>
- </property>
- <property name="acceptRichText">
- <bool>false</bool>
- </property>
- <property name="openLinks">
- <bool>false</bool>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QPushButton" name="saveButton">
- <property name="enabled">
- <bool>false</bool>
- </property>
- <property name="toolTip">
- <string>Save changes to the file.</string>
- </property>
- <property name="whatsThis">
- <string>Save changes to the file. This overwrites the original. There is no automatic backup!</string>
- </property>
- <property name="text">
- <string>Save</string>
+ <widget class="QWidget" name="layoutWidget">
+ <layout class="QVBoxLayout" name="verticalLayout_5" stretch="0,0,0">
+ <property name="spacing">
+ <number>6</number>
</property>
- </widget>
- </item>
- </layout>
+ <item>
+ <widget class="QLabel" name="label_6">
+ <property name="text">
+ <string>Ini Files</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListView" name="iniFileList">
+ <property name="toolTip">
+ <string>This is a list of .ini files in the mod.</string>
+ </property>
+ <property name="whatsThis">
+ <string>This is a list of .ini files in the mod. These are usually used to configure the behaviour of mods if there are configurable parameters.</string>
+ </property>
+ <property name="uniformItemSizes">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="iniFileFilter"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="layoutWidget1">
+ <layout class="QVBoxLayout" name="verticalLayout_4" stretch="0">
+ <item>
+ <widget class="TextEditor" name="iniFileEditor">
+ <property name="toolTip">
+ <string/>
+ </property>
+ <property name="whatsThis">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </widget>
</item>
</layout>
</widget>
@@ -190,67 +162,19 @@ <attribute name="title">
<string>Images</string>
</attribute>
- <layout class="QVBoxLayout" name="verticalLayout_2">
+ <layout class="QVBoxLayout" name="verticalLayout_19">
<item>
- <widget class="QLabel" name="imageLabel">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
- <horstretch>0</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
- </property>
- <property name="minimumSize">
- <size>
- <width>0</width>
- <height>0</height>
- </size>
- </property>
- <property name="maximumSize">
- <size>
- <width>16777215</width>
- <height>16777215</height>
- </size>
- </property>
- <property name="layoutDirection">
- <enum>Qt::LeftToRight</enum>
- </property>
- <property name="text">
- <string/>
- </property>
- <property name="alignment">
- <set>Qt::AlignCenter</set>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QScrollArea" name="scrollArea">
- <property name="maximumSize">
- <size>
- <width>16777215</width>
- <height>128</height>
- </size>
+ <widget class="QSplitter" name="tabImagesSplitter">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
</property>
- <property name="widgetResizable">
- <bool>true</bool>
+ <property name="childrenCollapsible">
+ <bool>false</bool>
</property>
- <widget class="QWidget" name="scrollAreaWidgetContents">
- <property name="geometry">
- <rect>
- <x>0</x>
- <y>0</y>
- <width>746</width>
- <height>126</height>
- </rect>
- </property>
- <property name="toolTip">
- <string>Images located in the mod.</string>
- </property>
- <property name="whatsThis">
- <string>This lists all the images (.jpg and .png) in the mod directory, like screenshots and such. Click one to get a larger view.</string>
- </property>
- <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <widget class="QWidget" name="widget_8" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_2" stretch="1,0,0">
<property name="spacing">
- <number>0</number>
+ <number>6</number>
</property>
<property name="leftMargin">
<number>0</number>
@@ -265,11 +189,110 @@ <number>0</number>
</property>
<item>
- <layout class="QHBoxLayout" name="thumbnailArea">
- <property name="spacing">
- <number>0</number>
+ <widget class="QWidget" name="imagesScroller" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout_4">
+ <property name="spacing">
+ <number>0</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="ImagesTabHelpers::ThumbnailsWidget" name="imagesThumbnails" native="true">
+ <property name="focusPolicy">
+ <enum>Qt::StrongFocus</enum>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="ImagesTabHelpers::Scrollbar" name="imagesScrollerVBar">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QCheckBox" name="imagesShowDDS">
+ <property name="text">
+ <string>Show .dds files</string>
</property>
- </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="imagesFilter"/>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="imagesContainer" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_18" stretch="0,1">
+ <property name="spacing">
+ <number>3</number>
+ </property>
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QWidget" name="widget_12" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout_3">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QToolButton" name="imagesExplore">
+ <property name="text">
+ <string>Open in Explorer</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="imagesPath">
+ <property name="readOnly">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="imagesSize">
+ <property name="text">
+ <string>0x0</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <widget class="QWidget" name="imagesImage" native="true"/>
</item>
</layout>
</widget>
@@ -281,117 +304,181 @@ <attribute name="title">
<string>Optional ESPs</string>
</attribute>
- <layout class="QFormLayout" name="formLayout">
- <item row="1" column="1">
- <widget class="QListWidget" name="inactiveESPList">
- <property name="toolTip">
- <string>List of esps, esms, and esls that can not be loaded by the game.</string>
- </property>
- <property name="whatsThis">
- <string>List of esps, esms, and esls contained in this plugin that currently can not be loaded by the game. They will not even appear in the esp-list in the main MO-window.
-They usually contain optional functionality, see the readme.
-
-Most mods do not have optional esps, so chances are good you are looking at an empty list.</string>
- </property>
- </widget>
- </item>
- <item row="1" column="0">
- <widget class="QLabel" name="label_2">
- <property name="text">
- <string>Optional ESPs</string>
+ <layout class="QVBoxLayout" name="verticalLayout_23">
+ <item>
+ <widget class="QSplitter" name="ESPsSplitter">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
</property>
- </widget>
- </item>
- <item row="2" column="1">
- <layout class="QHBoxLayout" name="horizontalLayout_5">
- <item>
- <widget class="QToolButton" name="deactivateESP">
- <property name="minimumSize">
- <size>
- <width>96</width>
- <height>0</height>
- </size>
- </property>
- <property name="toolTip">
- <string>Make the selected mod in the lower list unavailable.</string>
- </property>
- <property name="whatsThis">
- <string>The selected esp (in the lower list) will be pushed into a subdirectory of the mod and will thus become "invisible" to the game. It can then no longer be activated.</string>
- </property>
- <property name="text">
- <string/>
- </property>
- <property name="icon">
- <iconset resource="resources.qrc">
- <normaloff>:/MO/gui/resources/go-up.png</normaloff>:/MO/gui/resources/go-up.png</iconset>
+ <widget class="QWidget" name="widget_9" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_20">
+ <property name="leftMargin">
+ <number>0</number>
</property>
- <property name="iconSize">
- <size>
- <width>22</width>
- <height>22</height>
- </size>
+ <property name="topMargin">
+ <number>0</number>
</property>
- </widget>
- </item>
- <item>
- <widget class="QToolButton" name="activateESP">
- <property name="minimumSize">
- <size>
- <width>96</width>
- <height>0</height>
- </size>
+ <property name="rightMargin">
+ <number>0</number>
</property>
- <property name="toolTip">
- <string>Move a file to the data directory.</string>
+ <property name="bottomMargin">
+ <number>0</number>
</property>
- <property name="whatsThis">
- <string>This moves a esp to the esp directory so it can be enabled in the main window. Please note that the ESP merely becomes "available", it will not necessarily be loaded! That is configured in the main window of omo.</string>
+ <item>
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>Optional ESPs</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListView" name="inactiveESPList">
+ <property name="toolTip">
+ <string>List of esps, esms, and esls that can not be loaded by the game.</string>
+ </property>
+ <property name="whatsThis">
+ <string>List of esps, esms, and esls contained in this plugin that currently can not be loaded by the game. They will not even appear in the esp-list in the main MO-window.
+They usually contain optional functionality, see the readme.
+
+Most mods do not have optional esps, so chances are good you are looking at an empty list.</string>
+ </property>
+ <property name="uniformItemSizes">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="widget_11" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <property name="leftMargin">
+ <number>0</number>
</property>
- <property name="text">
- <string/>
+ <property name="topMargin">
+ <number>0</number>
</property>
- <property name="icon">
- <iconset resource="resources.qrc">
- <normaloff>:/MO/gui/resources/go-down.png</normaloff>:/MO/gui/resources/go-down.png</iconset>
+ <property name="rightMargin">
+ <number>0</number>
</property>
- <property name="iconSize">
- <size>
- <width>22</width>
- <height>22</height>
- </size>
+ <property name="bottomMargin">
+ <number>0</number>
</property>
- </widget>
- </item>
- </layout>
- </item>
- <item row="2" column="0">
- <spacer name="horizontalSpacer_2">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- <property name="sizeHint" stdset="0">
- <size>
- <width>40</width>
- <height>20</height>
- </size>
- </property>
- </spacer>
- </item>
- <item row="3" column="1">
- <widget class="QListWidget" name="activeESPList">
- <property name="toolTip">
- <string>ESPs in the data directory and thus visible to the game.</string>
- </property>
- <property name="whatsThis">
- <string>These are the mod files that are in the (virtual) data directory of your game and will thus be selectable in the esp list in the main window.</string>
- </property>
- </widget>
- </item>
- <item row="3" column="0">
- <widget class="QLabel" name="label">
- <property name="text">
- <string>Available ESPs</string>
- </property>
+ <item>
+ <layout class="QVBoxLayout" name="verticalLayout_22">
+ <item>
+ <spacer name="verticalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QToolButton" name="activateESP">
+ <property name="toolTip">
+ <string>Move a file to the data directory.</string>
+ </property>
+ <property name="whatsThis">
+ <string>This moves a esp to the esp directory so it can be enabled in the main window. Please note that the ESP merely becomes "available", it will not necessarily be loaded! That is configured in the main window of omo.</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/next</normaloff>:/MO/gui/next</iconset>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>22</width>
+ <height>22</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QToolButton" name="deactivateESP">
+ <property name="toolTip">
+ <string>Make the selected mod in the lower list unavailable.</string>
+ </property>
+ <property name="whatsThis">
+ <string>The selected esp (in the lower list) will be pushed into a subdirectory of the mod and will thus become "invisible" to the game. It can then no longer be activated.</string>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/previous</normaloff>:/MO/gui/previous</iconset>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>22</width>
+ <height>22</height>
+ </size>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QWidget" name="widget_10" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_21">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Available ESPs</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QListView" name="activeESPList">
+ <property name="toolTip">
+ <string>ESPs in the data directory and thus visible to the game.</string>
+ </property>
+ <property name="whatsThis">
+ <string><html><head/><body><p>These are the mod files that are in the (virtual) data directory of your game and will thus be selectable in the esp list in the main window.</p></body></html></string>
+ </property>
+ <property name="uniformItemSizes">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
</widget>
</item>
</layout>
@@ -471,7 +558,7 @@ text-align: left;</string> </layout>
</item>
<item>
- <widget class="QTreeWidget" name="overwriteTree">
+ <widget class="QTreeView" name="overwriteTree">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
@@ -487,28 +574,6 @@ text-align: left;</string> <property name="sortingEnabled">
<bool>true</bool>
</property>
- <property name="animated">
- <bool>true</bool>
- </property>
- <property name="columnCount">
- <number>2</number>
- </property>
- <attribute name="headerDefaultSectionSize">
- <number>365</number>
- </attribute>
- <attribute name="headerMinimumSectionSize">
- <number>200</number>
- </attribute>
- <column>
- <property name="text">
- <string>File</string>
- </property>
- </column>
- <column>
- <property name="text">
- <string>Overwritten Mods</string>
- </property>
- </column>
</widget>
</item>
<item>
@@ -547,7 +612,7 @@ text-align: left;</string> </layout>
</item>
<item>
- <widget class="QTreeWidget" name="overwrittenTree">
+ <widget class="QTreeView" name="overwrittenTree">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
@@ -563,28 +628,6 @@ text-align: left;</string> <property name="sortingEnabled">
<bool>true</bool>
</property>
- <property name="animated">
- <bool>true</bool>
- </property>
- <property name="columnCount">
- <number>2</number>
- </property>
- <attribute name="headerDefaultSectionSize">
- <number>365</number>
- </attribute>
- <attribute name="headerMinimumSectionSize">
- <number>200</number>
- </attribute>
- <column>
- <property name="text">
- <string>File</string>
- </property>
- </column>
- <column>
- <property name="text">
- <string>Providing Mod</string>
- </property>
- </column>
</widget>
</item>
<item>
@@ -623,7 +666,7 @@ text-align: left;</string> </layout>
</item>
<item>
- <widget class="QTreeWidget" name="noConflictTree">
+ <widget class="QTreeView" name="noConflictTree">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
@@ -639,17 +682,6 @@ text-align: left;</string> <property name="sortingEnabled">
<bool>true</bool>
</property>
- <property name="animated">
- <bool>true</bool>
- </property>
- <property name="columnCount">
- <number>1</number>
- </property>
- <column>
- <property name="text">
- <string>File</string>
- </property>
- </column>
</widget>
</item>
<item>
@@ -706,7 +738,7 @@ text-align: left;</string> <number>0</number>
</property>
<item>
- <widget class="QTreeWidget" name="conflictsAdvancedList">
+ <widget class="QTreeView" name="conflictsAdvancedList">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
@@ -719,27 +751,6 @@ text-align: left;</string> <property name="sortingEnabled">
<bool>true</bool>
</property>
- <property name="columnCount">
- <number>3</number>
- </property>
- <attribute name="headerDefaultSectionSize">
- <number>243</number>
- </attribute>
- <column>
- <property name="text">
- <string>Overwrites</string>
- </property>
- </column>
- <column>
- <property name="text">
- <string>File</string>
- </property>
- </column>
- <column>
- <property name="text">
- <string>Overwritten by</string>
- </property>
- </column>
</widget>
</item>
</layout>
@@ -839,10 +850,7 @@ text-align: left;</string> </attribute>
<layout class="QVBoxLayout" name="verticalLayout_7">
<item>
- <widget class="QTreeWidget" name="categoriesTree">
- <property name="animated">
- <bool>true</bool>
- </property>
+ <widget class="QTreeWidget" name="categories">
<attribute name="headerVisible">
<bool>false</bool>
</attribute>
@@ -863,7 +871,7 @@ text-align: left;</string> </widget>
</item>
<item>
- <widget class="QComboBox" name="primaryCategoryBox"/>
+ <widget class="QComboBox" name="primaryCategories"/>
</item>
</layout>
</item>
@@ -879,202 +887,268 @@ text-align: left;</string> </attribute>
<layout class="QVBoxLayout" name="verticalLayout_6">
<item>
- <layout class="QHBoxLayout" name="horizontalLayout_6">
- <item>
- <widget class="QLabel" name="label_3">
- <property name="text">
- <string>Mod ID</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="ModIDLineEdit" name="modIDEdit">
- <property name="toolTip">
- <string>Mod ID for this mod on Nexus.</string>
- </property>
- <property name="whatsThis">
- <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
+ <widget class="QWidget" name="widget_14" native="true">
+ <layout class="QVBoxLayout" name="verticalLayout_25">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <layout class="QHBoxLayout" name="horizontalLayout_6" stretch="0,0,0,0,0,0,1">
+ <item>
+ <widget class="QLabel" name="label_3">
+ <property name="text">
+ <string>Mod ID</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="ModIDLineEdit" name="modID">
+ <property name="toolTip">
+ <string>Mod ID for this mod on Nexus.</string>
+ </property>
+ <property name="whatsThis">
+ <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
p, li { white-space: pre-wrap; }
</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8pt; font-weight:400; font-style:normal;">
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Mod ID for this mod on Nexus. This is filled in automatically if you downloaded and installed the mod from inside MO. Otherwise you can enter it manually. To find the correct id, find the mod on nexus. The URL will look like this: <a href=" https://www.nexusmods.com/skyrimspecialedition/mods/6194"><span style=" text-decoration: underline; color:#0000ff;">https://www.nexusmods.com/skyrimspecialedition/mods/6194</span></a>. In this example, 6194 is the id you're looking for. Besides: The above is the link to Mod Organizer 2 on Nexus. Why not go there now and endorse us?</p></body></html></string>
- </property>
- </widget>
- </item>
- <item>
- <spacer name="horizontalSpacer_4">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- <property name="sizeType">
- <enum>QSizePolicy::Fixed</enum>
- </property>
- <property name="sizeHint" stdset="0">
- <size>
- <width>10</width>
- <height>20</height>
- </size>
- </property>
- </spacer>
- </item>
- <item>
- <widget class="QLabel" name="label_10">
- <property name="text">
- <string>Source Game</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QComboBox" name="sourceGameEdit">
- <property name="toolTip">
- <string>Source game for this mod.</string>
- </property>
- <property name="whatsThis">
- <string><html><head/><body><p>Source game for this mod. This determines where the mod was downloaded from and decides where to fetch info, version updates, and send endorsements. Changing this will likely require you to enter a new Mod ID.</p></body></html></string>
- </property>
- </widget>
- </item>
- <item>
- <spacer name="horizontalSpacer_3">
- <property name="orientation">
- <enum>Qt::Horizontal</enum>
- </property>
- <property name="sizeHint" stdset="0">
- <size>
- <width>40</width>
- <height>20</height>
- </size>
- </property>
- </spacer>
- </item>
- <item>
- <widget class="QLabel" name="versionLabel">
- <property name="whatsThis">
- <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_10">
+ <property name="text">
+ <string>Source Game</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QComboBox" name="sourceGame">
+ <property name="toolTip">
+ <string>Source game for this mod.</string>
+ </property>
+ <property name="whatsThis">
+ <string><html><head/><body><p>Source game for this mod. This determines where the mod was downloaded from and decides where to fetch info, version updates, and send endorsements. Changing this will likely require you to enter a new Mod ID.</p></body></html></string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="versionLabel">
+ <property name="whatsThis">
+ <string><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd">
<html><head><meta name="qrichtext" content="1" /><style type="text/css">
p, li { white-space: pre-wrap; }
</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; font-weight:400; font-style:normal;">
<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8pt;">Installed Version of the Mod. The tooltip will contain the current version available on nexus. The installed version is only set if you installed the mod through MO.</span></p></body></html></string>
- </property>
- <property name="text">
- <string>Version</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QLineEdit" name="versionEdit">
- <property name="maximumSize">
- <size>
- <width>150</width>
- <height>16777215</height>
- </size>
- </property>
- <property name="maxLength">
- <number>32</number>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QHBoxLayout" name="horizontalLayout_11" stretch="0,1">
- <item>
- <widget class="QPushButton" name="refreshButton">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
- <horstretch>0</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
- </property>
- <property name="toolTip">
- <string>Refresh</string>
- </property>
- <property name="whatsThis">
- <string>Refresh all information from Nexus.</string>
- </property>
- <property name="text">
- <string/>
- </property>
- <property name="icon">
- <iconset resource="resources.qrc">
- <normaloff>:/MO/gui/refresh</normaloff>:/MO/gui/refresh</iconset>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QLabel" name="label_4">
- <property name="text">
- <string>Description</string>
- </property>
- </widget>
- </item>
- </layout>
+ </property>
+ <property name="text">
+ <string>Version</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="version">
+ <property name="maxLength">
+ <number>32</number>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item>
+ <widget class="QWidget" name="widget_15" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout_7">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QPushButton" name="refresh">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Minimum" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="toolTip">
+ <string>Refresh</string>
+ </property>
+ <property name="whatsThis">
+ <string>Refresh all information from Nexus.</string>
+ </property>
+ <property name="text">
+ <string>Refresh</string>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/refresh</normaloff>:/MO/gui/refresh</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="visitNexus">
+ <property name="text">
+ <string>Open in Browser</string>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/resources/internet-web-browser.png</normaloff>:/MO/gui/resources/internet-web-browser.png</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="endorse">
+ <property name="text">
+ <string>Endorse</string>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/icon_favorite</normaloff>:/MO/gui/icon_favorite</iconset>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="track">
+ <property name="text">
+ <string>Track</string>
+ </property>
+ <property name="icon">
+ <iconset resource="resources.qrc">
+ <normaloff>:/MO/gui/tracked</normaloff>:/MO/gui/tracked</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
</item>
<item>
- <widget class="QWebEngineView" name="descriptionView" native="true">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
- <horstretch>0</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
- </property>
- <property name="minimumSize">
- <size>
- <width>0</width>
- <height>200</height>
- </size>
+ <widget class="QFrame" name="frame">
+ <property name="frameShape">
+ <enum>QFrame::StyledPanel</enum>
</property>
- <property name="url" stdset="0">
- <url>
- <string>about:blank</string>
- </url>
+ <property name="frameShadow">
+ <enum>QFrame::Sunken</enum>
</property>
+ <layout class="QVBoxLayout" name="verticalLayout_24">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QWebEngineView" name="browser">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>200</height>
+ </size>
+ </property>
+ <property name="url">
+ <url>
+ <string>about:blank</string>
+ </url>
+ </property>
+ </widget>
+ </item>
+ </layout>
</widget>
</item>
<item>
- <layout class="QHBoxLayout" name="horizontalLayout_7">
- <item>
- <widget class="QLabel" name="visitNexusLabel">
- <property name="sizePolicy">
- <sizepolicy hsizetype="Preferred" vsizetype="Preferred">
- <horstretch>1</horstretch>
- <verstretch>0</verstretch>
- </sizepolicy>
- </property>
- <property name="text">
- <string/>
- </property>
- <property name="openExternalLinks">
- <bool>false</bool>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QPushButton" name="endorseBtn">
- <property name="text">
- <string>Endorse</string>
- </property>
- <property name="icon">
- <iconset resource="resources.qrc">
- <normaloff>:/MO/gui/icon_favorite</normaloff>:/MO/gui/icon_favorite</iconset>
- </property>
- </widget>
- </item>
- </layout>
- </item>
- <item>
- <layout class="QHBoxLayout" name="horizontalLayout_14">
- <item>
- <widget class="QLabel" name="label_11">
- <property name="text">
- <string>Web page URL (only used if invalid Nexus ID) :</string>
- </property>
- </widget>
- </item>
- <item>
- <widget class="QLineEdit" name="customUrlLineEdit"/>
- </item>
- </layout>
+ <widget class="QWidget" name="widget_13" native="true">
+ <layout class="QHBoxLayout" name="horizontalLayout_5">
+ <property name="leftMargin">
+ <number>0</number>
+ </property>
+ <property name="topMargin">
+ <number>0</number>
+ </property>
+ <property name="rightMargin">
+ <number>0</number>
+ </property>
+ <property name="bottomMargin">
+ <number>0</number>
+ </property>
+ <item>
+ <widget class="QCheckBox" name="hasCustomURL">
+ <property name="text">
+ <string>Use Custom URL</string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLineEdit" name="customURL"/>
+ </item>
+ <item>
+ <widget class="QPushButton" name="visitCustomURL">
+ <property name="text">
+ <string>Open in Browser</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
</item>
</layout>
</widget>
@@ -1084,7 +1158,7 @@ p, li { white-space: pre-wrap; } </attribute>
<layout class="QVBoxLayout" name="verticalLayout_10">
<item>
- <widget class="QLineEdit" name="commentsEdit">
+ <widget class="QLineEdit" name="comments">
<property name="toolTip">
<string>Enter comments about the mod here. These are displayed in the notes column of the mod list.</string>
</property>
@@ -1097,7 +1171,7 @@ p, li { white-space: pre-wrap; } </widget>
</item>
<item>
- <widget class="QTextEdit" name="notesEdit">
+ <widget class="HTMLEditor" name="notes">
<property name="toolTip">
<string>Enter notes about the mod here. These can be viewed in the mod list by hovering over the notes column or the flags column.</string>
</property>
@@ -1122,7 +1196,7 @@ p, li { white-space: pre-wrap; } <item>
<layout class="QHBoxLayout" name="horizontalLayout_13">
<item>
- <widget class="QPushButton" name="openInExplorerButton">
+ <widget class="QToolButton" name="openInExplorer">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
@@ -1150,7 +1224,7 @@ p, li { white-space: pre-wrap; } </layout>
</item>
<item>
- <widget class="QTreeView" name="fileTree">
+ <widget class="QTreeView" name="filetree">
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
@@ -1174,6 +1248,9 @@ p, li { white-space: pre-wrap; } <property name="selectionMode">
<enum>QAbstractItemView::ExtendedSelection</enum>
</property>
+ <property name="uniformRowHeights">
+ <bool>true</bool>
+ </property>
</widget>
</item>
</layout>
@@ -1183,17 +1260,23 @@ p, li { white-space: pre-wrap; } <item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
- <widget class="QPushButton" name="prevButton">
+ <widget class="QPushButton" name="previousMod">
<property name="text">
<string>Previous</string>
</property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
</widget>
</item>
<item>
- <widget class="QPushButton" name="nextButton">
+ <widget class="QPushButton" name="nextMod">
<property name="text">
<string>Next</string>
</property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
</widget>
</item>
<item>
@@ -1210,10 +1293,13 @@ p, li { white-space: pre-wrap; } </spacer>
</item>
<item>
- <widget class="QPushButton" name="closeButton">
+ <widget class="QPushButton" name="close">
<property name="text">
<string>Close</string>
</property>
+ <property name="autoDefault">
+ <bool>false</bool>
+ </property>
</widget>
</item>
</layout>
@@ -1231,6 +1317,27 @@ p, li { white-space: pre-wrap; } <extends>QLineEdit</extends>
<header>modidlineedit.h</header>
</customwidget>
+ <customwidget>
+ <class>TextEditor</class>
+ <extends>QPlainTextEdit</extends>
+ <header>texteditor.h</header>
+ </customwidget>
+ <customwidget>
+ <class>HTMLEditor</class>
+ <extends>QTextEdit</extends>
+ <header>texteditor.h</header>
+ </customwidget>
+ <customwidget>
+ <class>ImagesTabHelpers::ThumbnailsWidget</class>
+ <extends>QWidget</extends>
+ <header>modinfodialogimages.h</header>
+ <container>1</container>
+ </customwidget>
+ <customwidget>
+ <class>ImagesTabHelpers::Scrollbar</class>
+ <extends>QScrollBar</extends>
+ <header>modinfodialogimages.h</header>
+ </customwidget>
</customwidgets>
<resources>
<include location="resources.qrc"/>
diff --git a/src/modinfodialogcategories.cpp b/src/modinfodialogcategories.cpp new file mode 100644 index 00000000..c61e248e --- /dev/null +++ b/src/modinfodialogcategories.cpp @@ -0,0 +1,137 @@ +#include "modinfodialogcategories.h" +#include "ui_modinfodialog.h" +#include "categories.h" +#include "modinfo.h" + +CategoriesTab::CategoriesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)) +{ + connect( + ui->categories, &QTreeWidget::itemChanged, + [&](auto* item, int col){ onCategoryChanged(item, col); }); + + connect( + ui->primaryCategories, + static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), + [&](int index){ onPrimaryChanged(index); }); +} + +void CategoriesTab::clear() +{ + ui->categories->clear(); + ui->primaryCategories->clear(); + setHasData(false); +} + +void CategoriesTab::update() +{ + clear(); + + add( + CategoryFactory::instance(), mod().getCategories(), + ui->categories->invisibleRootItem(), 0); + + updatePrimary(); +} + +bool CategoriesTab::canHandleSeparators() const +{ + return true; +} + +bool CategoriesTab::usesOriginFiles() const +{ + return false; +} + +void CategoriesTab::add( + const CategoryFactory &factory, const std::set<int>& enabledCategories, + QTreeWidgetItem* root, int rootLevel) +{ + for (int i=0; i<static_cast<int>(factory.numCategories()); ++i) { + if (factory.getParentID(i) != rootLevel) { + continue; + } + + int categoryID = factory.getCategoryID(i); + + QTreeWidgetItem *newItem + = new QTreeWidgetItem(QStringList(factory.getCategoryName(i))); + + newItem->setFlags(newItem->flags() | Qt::ItemIsUserCheckable); + + newItem->setCheckState(0, enabledCategories.find(categoryID) + != enabledCategories.end() + ? Qt::Checked + : Qt::Unchecked); + + newItem->setData(0, Qt::UserRole, categoryID); + + if (factory.hasChildren(i)) { + add(factory, enabledCategories, newItem, categoryID); + } + + root->addChild(newItem); + } +} + +void CategoriesTab::updatePrimary() +{ + ui->primaryCategories->clear(); + + int primaryCategory = mod().getPrimaryCategory(); + + addChecked(ui->categories->invisibleRootItem()); + + for (int i = 0; i < ui->primaryCategories->count(); ++i) { + if (ui->primaryCategories->itemData(i).toInt() == primaryCategory) { + ui->primaryCategories->setCurrentIndex(i); + break; + } + } + + setHasData(ui->primaryCategories->count() > 0); +} + +void CategoriesTab::addChecked(QTreeWidgetItem* tree) +{ + for (int i = 0; i < tree->childCount(); ++i) { + QTreeWidgetItem *child = tree->child(i); + if (child->checkState(0) == Qt::Checked) { + ui->primaryCategories->addItem(child->text(0), child->data(0, Qt::UserRole)); + addChecked(child); + } + } +} + +void CategoriesTab::save(QTreeWidgetItem* currentNode) +{ + for (int i = 0; i < currentNode->childCount(); ++i) { + QTreeWidgetItem *childNode = currentNode->child(i); + + mod().setCategory( + childNode->data(0, Qt::UserRole).toInt(), childNode->checkState(0)); + + save(childNode); + } +} + +void CategoriesTab::onCategoryChanged(QTreeWidgetItem* item, int) +{ + QTreeWidgetItem *parent = item->parent(); + + while ((parent != nullptr) && ((parent->flags() & Qt::ItemIsUserCheckable) != 0) && (parent->checkState(0) == Qt::Unchecked)) { + parent->setCheckState(0, Qt::Checked); + parent = parent->parent(); + } + + updatePrimary(); + save(ui->categories->invisibleRootItem()); +} + +void CategoriesTab::onPrimaryChanged(int index) +{ + if (index != -1) { + mod().setPrimaryCategory(ui->primaryCategories->itemData(index).toInt()); + } +} diff --git a/src/modinfodialogcategories.h b/src/modinfodialogcategories.h new file mode 100644 index 00000000..c8b52fec --- /dev/null +++ b/src/modinfodialogcategories.h @@ -0,0 +1,27 @@ +#include "modinfodialogtab.h" + +class CategoryFactory; + +class CategoriesTab : public ModInfoDialogTab +{ +public: + CategoriesTab(ModInfoDialogTabContext cx); + + void clear() override; + void update() override; + bool canHandleSeparators() const override; + bool usesOriginFiles() const override; + +private: + void add( + const CategoryFactory& factory, const std::set<int>& enabledCategories, + QTreeWidgetItem* root, int rootLevel); + + void updatePrimary(); + void addChecked(QTreeWidgetItem* tree); + + void save(QTreeWidgetItem* currentNode); + + void onCategoryChanged(QTreeWidgetItem* item, int col); + void onPrimaryChanged(int index); +}; diff --git a/src/modinfodialogconflicts.cpp b/src/modinfodialogconflicts.cpp new file mode 100644 index 00000000..511d48ad --- /dev/null +++ b/src/modinfodialogconflicts.cpp @@ -0,0 +1,1229 @@ +#include "modinfodialogconflicts.h" +#include "ui_modinfodialog.h" +#include "modinfodialog.h" +#include "utility.h" +#include "settings.h" +#include "organizercore.h" + +using namespace MOShared; +using namespace MOBase; +namespace shell = MOBase::shell; + +// if there are more than 50 selected items in the conflict tree, don't bother +// checking whether menu items apply to them, just show all of them +const std::size_t max_small_selection = 50; + + +class ConflictItem +{ +public: + ConflictItem( + QString before, QString relativeName, QString after, + FileEntry::Index index, QString fileName, + bool hasAltOrigins, QString altOrigin, bool archive) : + m_before(std::move(before)), + m_relativeName(std::move(relativeName)), + m_after(std::move(after)), + m_index(index), + m_fileName(std::move(fileName)), + m_hasAltOrigins(hasAltOrigins), + m_altOrigin(std::move(altOrigin)), + m_isArchive(archive) + { + } + + const QString& before() const + { + return m_before; + } + + const QString& relativeName() const + { + return m_relativeName; + } + + const QString& after() const + { + return m_after; + } + + const QString& fileName() const + { + return m_fileName; + } + + const QString& altOrigin() const + { + return m_altOrigin; + } + + bool hasAlts() const + { + return m_hasAltOrigins; + } + + bool isArchive() const + { + return m_isArchive; + } + + FileEntry::Index fileIndex() const + { + return m_index; + } + + bool canHide() const + { + return canHideFile(isArchive(), fileName()); + } + + bool canUnhide() const + { + return canUnhideFile(isArchive(), fileName()); + } + + bool canOpen() const + { + return canOpenFile(isArchive(), fileName()); + } + + bool canPreview(PluginContainer& pluginContainer) const + { + return canPreviewFile(pluginContainer, isArchive(), fileName()); + } + + bool canExplore() const + { + return canExploreFile(isArchive(), fileName()); + } + +private: + QString m_before; + QString m_relativeName; + QString m_after; + FileEntry::Index m_index; + QString m_fileName; + bool m_hasAltOrigins; + QString m_altOrigin; + bool m_isArchive; +}; + + +class ConflictListModel : public QAbstractItemModel +{ +public: + struct Column + { + QString caption; + const QString& (ConflictItem::*getText)() const; + }; + + ConflictListModel(QTreeView* tree, std::vector<Column> columns) : + m_tree(tree), m_columns(std::move(columns)), + m_sortColumn(-1), m_sortOrder(Qt::AscendingOrder) + { + m_tree->setModel(this); + } + + void clear() + { + m_items.clear(); + endResetModel(); + } + + void reserve(std::size_t s) + { + m_items.reserve(s); + } + + QModelIndex index(int row, int col, const QModelIndex& ={}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override + { + return {}; + } + + int rowCount(const QModelIndex& parent={}) const override + { + if (parent.isValid()) { + return 0; + } + + return static_cast<int>(m_items.size()); + } + + int columnCount(const QModelIndex& ={}) const override + { + return static_cast<int>(m_columns.size()); + } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole || role == Qt::FontRole) { + const auto row = index.row(); + if (row < 0) { + return {}; + } + + const auto i = static_cast<std::size_t>(row); + if (i >= m_items.size()) { + return {}; + } + + const auto col = index.column(); + if (col < 0) { + return {}; + } + + const auto c = static_cast<std::size_t>(col); + if (c >= m_columns.size()) { + return {}; + } + + const auto& item = m_items[i]; + + if (role == Qt::DisplayRole) { + return (item.*m_columns[c].getText)(); + } else if (role == Qt::FontRole) { + if (item.isArchive()) { + QFont f = m_tree->font(); + f.setItalic(true); + return f; + } + } + } + + return {}; + } + + QVariant headerData(int col, Qt::Orientation, int role) const + { + if (role == Qt::DisplayRole) { + if (col < 0) { + return {}; + } + + const auto i = static_cast<std::size_t>(col); + if (i >= m_columns.size()) { + return {}; + } + + return m_columns[i].caption; + } + + return {}; + } + + void sort(int colIndex, Qt::SortOrder order=Qt::AscendingOrder) + { + m_sortColumn = colIndex; + m_sortOrder = order; + + doSort(); + emit layoutChanged({}, QAbstractItemModel::VerticalSortHint); + } + + void add(ConflictItem item) + { + m_items.emplace_back(std::move(item)); + } + + void finished() + { + endResetModel(); + doSort(); + } + + const ConflictItem* getItem(std::size_t row) const + { + if (row >= m_items.size()) { + return nullptr; + } + + return &m_items[row]; + } + +private: + QTreeView* m_tree; + std::vector<Column> m_columns; + std::vector<ConflictItem> m_items; + int m_sortColumn; + Qt::SortOrder m_sortOrder; + + void doSort() + { + if (m_items.empty()) { + return; + } + + if (m_sortColumn < 0) { + return; + } + + const auto c = static_cast<std::size_t>(m_sortColumn); + if (c >= m_columns.size()) { + return; + } + + const auto& col = m_columns[c]; + + // avoids branching on sort order while sorting + auto sortAsc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) < 0); + }; + + auto sortDesc = [&](const auto& a, const auto& b) { + return (naturalCompare((a.*col.getText)(), (b.*col.getText)()) > 0); + }; + + if (m_sortOrder == Qt::AscendingOrder) { + std::sort(m_items.begin(), m_items.end(), sortAsc); + } else { + std::sort(m_items.begin(), m_items.end(), sortDesc); + } + } +}; + + +class OverwriteConflictListModel : public ConflictListModel +{ +public: + OverwriteConflictListModel(QTreeView* tree) + : ConflictListModel(tree, { + {tr("File"), &ConflictItem::relativeName}, + {tr("Overwritten Mods"), &ConflictItem::before} + }) + { + } +}; + + +class OverwrittenConflictListModel : public ConflictListModel +{ +public: + OverwrittenConflictListModel(QTreeView* tree) + : ConflictListModel(tree, { + {tr("File"), &ConflictItem::relativeName}, + {tr("Providing Mod"), &ConflictItem::after} + }) + { + } +}; + + +class NoConflictListModel : public ConflictListModel +{ +public: + NoConflictListModel(QTreeView* tree) + : ConflictListModel(tree, { + {tr("File"), &ConflictItem::relativeName} + }) + { + } +}; + + +class AdvancedConflictListModel : public ConflictListModel +{ +public: + AdvancedConflictListModel(QTreeView* tree) + : ConflictListModel(tree, { + {tr("Overwrites"), &ConflictItem::before}, + {tr("File"), &ConflictItem::relativeName}, + {tr("Overwritten By"), &ConflictItem::after} + }) + { + } +}; + + +std::size_t smallSelectionSize(const QTreeView* tree) +{ + const std::size_t too_many = std::numeric_limits<std::size_t>::max(); + + std::size_t n = 0; + const auto* sel = tree->selectionModel(); + + for (const auto& range : sel->selection()) { + n += range.height(); + + if (n >= max_small_selection) { + return too_many; + } + } + + return n; +} + +template <class F> +void for_each_in_selection(QTreeView* tree, F&& f) +{ + const auto* sel = tree->selectionModel(); + const auto* model = dynamic_cast<ConflictListModel*>(tree->model()); + + if (!model) { + qCritical() << "tree doesn't have a ConflictListModel"; + return; + } + + for (const auto& range : sel->selection()) { + // ranges are inclusive + for (int row=range.top(); row<=range.bottom(); ++row) { + if (auto* item=model->getItem(static_cast<std::size_t>(row))) { + if (!f(item)) { + return; + } + } + } + } +} + + +ConflictsTab::ConflictsTab(ModInfoDialogTabContext cx) : + ModInfoDialogTab(cx), // don't move, cx is used again + m_general(this, cx.ui, cx.core), m_advanced(this, cx.ui, cx.core) +{ + connect( + &m_general, &GeneralConflictsTab::modOpen, + [&](const QString& name){ emitModOpen(name); }); + + connect( + &m_advanced, &AdvancedConflictsTab::modOpen, + [&](const QString& name){ emitModOpen(name); }); +} + +void ConflictsTab::update() +{ + setHasData(m_general.update()); + m_advanced.update(); +} + +void ConflictsTab::clear() +{ + m_general.clear(); + m_advanced.clear(); + setHasData(false); +} + +void ConflictsTab::saveState(Settings& s) +{ + s.directInterface().setValue( + "mod_info_conflicts_tab", ui->tabConflictsTabs->currentIndex()); + + m_general.saveState(s); + m_advanced.saveState(s); +} + +void ConflictsTab::restoreState(const Settings& s) +{ + ui->tabConflictsTabs->setCurrentIndex( + s.directInterface().value("mod_info_conflicts_tab", 0).toInt()); + + m_general.restoreState(s); + m_advanced.restoreState(s); +} + +bool ConflictsTab::canHandleUnmanaged() const +{ + return true; +} + +void ConflictsTab::changeItemsVisibility(QTreeView* tree, bool visible) +{ + bool changed = false; + bool stop = false; + + const auto n = smallSelectionSize(tree); + + qDebug().nospace().noquote() + << (visible ? "unhiding" : "hiding") << " " + << (n > max_small_selection ? "a lot of" : QString("%1").arg(n)) + << " conflict files"; + + QFlags<FileRenamer::RenameFlags> flags = + (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE); + + if (n > 1) { + flags |= FileRenamer::MULTIPLE; + } + + FileRenamer renamer(parentWidget(), flags); + + auto* model = dynamic_cast<ConflictListModel*>(tree->model()); + if (!model) { + qCritical() << "list doesn't have a ConflictListModel"; + return; + } + + for_each_in_selection(tree, [&](const ConflictItem* item) { + if (stop) { + return false; + } + + auto result = FileRenamer::RESULT_CANCEL; + + if (visible) { + if (!item->canUnhide()) { + qDebug().nospace() << "cannot unhide " << item->relativeName() << ", skipping"; + return true; + } + + result = unhideFile(renamer, item->fileName()); + + } else { + if (!item->canHide()) { + qDebug().nospace() << "cannot hide " << item->relativeName() << ", skipping"; + return true; + } + + result = hideFile(renamer, item->fileName()); + } + + switch (result) { + case FileRenamer::RESULT_OK: { + // will trigger a refresh at the end + changed = true; + break; + } + + case FileRenamer::RESULT_SKIP: { + // nop + break; + } + + case FileRenamer::RESULT_CANCEL: { + // stop right now, but make sure to refresh if needed + stop = true; + break; + } + } + + return true; + }); + + qDebug().nospace() << (visible ? "unhiding" : "hiding") << " conflict files done"; + + if (changed) { + qDebug().nospace() << "triggering refresh"; + + if (origin()) { + emitOriginModified(); + } + + update(); + } +} + +void ConflictsTab::openItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + for_each_in_selection(tree, [&](const ConflictItem* item) { + core().executeFileVirtualized(parentWidget(), item->fileName()); + return true; + }); +} + +void ConflictsTab::previewItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + for_each_in_selection(tree, [&](const ConflictItem* item) { + core().previewFileWithAlternatives(parentWidget(), item->fileName()); + return true; + }); +} + +void ConflictsTab::exploreItems(QTreeView* tree) +{ + // the menu item is only shown for a single selection, but handle all of them + // in case this changes + for_each_in_selection(tree, [&](const ConflictItem* item) { + shell::ExploreFile(item->fileName()); + return true; + }); +} + +void ConflictsTab::showContextMenu(const QPoint &pos, QTreeView* tree) +{ + auto actions = createMenuActions(tree); + + QMenu menu; + + // open + if (actions.open) { + connect(actions.open, &QAction::triggered, [&]{ + openItems(tree); + }); + + menu.addAction(actions.open); + } + + // preview + if (actions.preview) { + connect(actions.preview, &QAction::triggered, [&]{ + previewItems(tree); + }); + + menu.addAction(actions.preview); + } + + // explore + if (actions.explore) { + connect(actions.explore, &QAction::triggered, [&]{ + exploreItems(tree); + }); + + menu.addAction(actions.explore); + } + + // hide + if (actions.hide) { + connect(actions.hide, &QAction::triggered, [&]{ + changeItemsVisibility(tree, false); + }); + + menu.addAction(actions.hide); + } + + // unhide + if (actions.unhide) { + connect(actions.unhide, &QAction::triggered, [&]{ + changeItemsVisibility(tree, true); + }); + + menu.addAction(actions.unhide); + } + + // goto + if (actions.gotoMenu) { + menu.addMenu(actions.gotoMenu); + + for (auto* a : actions.gotoActions) { + connect(a, &QAction::triggered, [&, name=a->text()]{ + emitModOpen(name); + }); + + actions.gotoMenu->addAction(a); + } + } + + if (!menu.isEmpty()) { + menu.exec(tree->viewport()->mapToGlobal(pos)); + } +} + +ConflictsTab::Actions ConflictsTab::createMenuActions(QTreeView* tree) +{ + if (tree->selectionModel()->selection().isEmpty()) { + return {}; + } + + bool enableHide = true; + bool enableUnhide = true; + bool enableOpen = true; + bool enablePreview = true; + bool enableExplore = true; + bool enableGoto = true; + + const auto n = smallSelectionSize(tree); + + const auto* model = dynamic_cast<ConflictListModel*>(tree->model()); + if (!model) { + qCritical() << "tree doesn't have a ConflictListModel"; + return {}; + } + + if (n == 1) { + // this is a single selection + const auto* item = model->getItem(static_cast<std::size_t>( + tree->selectionModel()->selectedRows()[0].row())); + + if (!item) { + return {}; + } + + enableHide = item->canHide(); + enableUnhide = item->canUnhide(); + enableOpen = item->canOpen(); + enablePreview = item->canPreview(plugin()); + enableExplore = item->canExplore(); + enableGoto = item->hasAlts(); + } + else { + // this is a multiple selection, don't show open/preview so users don't open + // a thousand files + enableOpen = false; + enablePreview = false; + + // can't explore multiple files + enableExplore = false; + + // don't bother with this on multiple selection, at least for now + enableGoto = false; + + if (n <= max_small_selection) { + // if the number of selected items is low, checking them to accurately + // show the menu items is worth it + enableHide = false; + enableUnhide = false; + + for_each_in_selection(tree, [&](const ConflictItem* item) { + if (item->canHide()) { + enableHide = true; + } + + if (item->canUnhide()) { + enableUnhide = true; + } + + if (enableHide && enableUnhide && enableGoto) { + // found all, no need to check more + return false; + } + + return true; + }); + } + } + + Actions actions; + + actions.hide = new QAction(tr("&Hide"), parentWidget()); + actions.hide->setEnabled(enableHide); + + // note that it is possible for hidden files to appear if they override other + // hidden files from another mod + actions.unhide = new QAction(tr("&Unhide"), parentWidget()); + actions.unhide->setEnabled(enableUnhide); + + actions.open = new QAction(tr("&Open/Execute"), parentWidget()); + actions.open->setEnabled(enableOpen); + + actions.preview = new QAction(tr("&Preview"), parentWidget()); + actions.preview->setEnabled(enablePreview); + + actions.explore = new QAction(tr("Open in &Explorer"), parentWidget()); + actions.explore->setEnabled(enableExplore); + + actions.gotoMenu = new QMenu(tr("&Go to..."), parentWidget()); + actions.gotoMenu->setEnabled(enableGoto); + + if (enableGoto && n == 1) { + const auto* item = model->getItem(static_cast<std::size_t>( + tree->selectionModel()->selectedRows()[0].row())); + + actions.gotoActions = createGotoActions(item); + } + + return actions; +} + +std::vector<QAction*> ConflictsTab::createGotoActions(const ConflictItem* item) +{ + if (!origin()) { + return {}; + } + + auto file = origin()->findFile(item->fileIndex()); + if (!file) { + return {}; + } + + + std::vector<QString> mods; + const auto& ds = *core().directoryStructure(); + + // add all alternatives + for (const auto& alt : file->getAlternatives()) { + const auto& o = ds.getOriginByID(alt.first); + if (o.getID() != origin()->getID()) { + mods.push_back(ToQString(o.getName())); + } + } + + // add the real origin if different from this mod + const FilesOrigin& realOrigin = ds.getOriginByID(file->getOrigin()); + if (realOrigin.getID() != origin()->getID()) { + mods.push_back(ToQString(realOrigin.getName())); + } + + std::sort(mods.begin(), mods.end(), [](const auto& a, const auto& b) { + return (QString::localeAwareCompare(a, b) < 0); + }); + + std::vector<QAction*> actions; + + for (const auto& name : mods) { + actions.push_back(new QAction(name, parentWidget())); + } + + return actions; +} + + +GeneralConflictsTab::GeneralConflictsTab( + ConflictsTab* tab, Ui::ModInfoDialog* pui, OrganizerCore& oc) : + m_tab(tab), ui(pui), m_core(oc), + m_overwriteModel(new OverwriteConflictListModel(ui->overwriteTree)), + m_overwrittenModel(new OverwrittenConflictListModel(ui->overwrittenTree)), + m_noConflictModel(new NoConflictListModel(ui->noConflictTree)) +{ + m_expanders.overwrite.set(ui->overwriteExpander, ui->overwriteTree, true); + m_expanders.overwritten.set(ui->overwrittenExpander, ui->overwrittenTree, true); + m_expanders.nonconflict.set(ui->noConflictExpander, ui->noConflictTree); + + QObject::connect( + ui->overwriteTree, &QTreeView::doubleClicked, + [&](auto&& item){ onOverwriteActivated(item); }); + + QObject::connect( + ui->overwrittenTree, &QTreeView::doubleClicked, + [&](auto&& item){ onOverwrittenActivated(item); }); + + QObject::connect( + ui->overwriteTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p){ m_tab->showContextMenu(p, ui->overwriteTree); }); + + QObject::connect( + ui->overwrittenTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p){ m_tab->showContextMenu(p, ui->overwrittenTree); }); + + QObject::connect( + ui->noConflictTree, &QTreeView::customContextMenuRequested, + [&](const QPoint& p){ m_tab->showContextMenu(p, ui->noConflictTree); }); +} + +void GeneralConflictsTab::clear() +{ + m_overwriteModel->clear(); + m_overwrittenModel->clear(); + m_noConflictModel->clear(); + + ui->overwriteCount->display(0); + ui->overwrittenCount->display(0); + ui->noConflictCount->display(0); +} + +void GeneralConflictsTab::saveState(Settings& s) +{ + QByteArray result; + QDataStream stream(&result, QIODevice::WriteOnly); + + stream + << m_expanders.overwrite.opened() + << m_expanders.overwritten.opened() + << m_expanders.nonconflict.opened(); + + s.directInterface().setValue( + "mod_info_conflicts_general_expanders", result); + + s.directInterface().setValue( + "mod_info_conflicts_general_overwrite", + ui->overwriteTree->header()->saveState()); + + s.directInterface().setValue( + "mod_info_conflicts_general_noconflict", + ui->noConflictTree->header()->saveState()); + + s.directInterface().setValue( + "mod_info_conflicts_general_overwritten", + ui->overwrittenTree->header()->saveState()); +} + +void GeneralConflictsTab::restoreState(const Settings& s) +{ + QDataStream stream(s.directInterface() + .value("mod_info_conflicts_general_expanders").toByteArray()); + + bool overwriteExpanded = false; + bool overwrittenExpanded = false; + bool noConflictExpanded = false; + + stream >> overwriteExpanded >> overwrittenExpanded >> noConflictExpanded; + + if (stream.status() == QDataStream::Ok) { + m_expanders.overwrite.toggle(overwriteExpanded); + m_expanders.overwritten.toggle(overwrittenExpanded); + m_expanders.nonconflict.toggle(noConflictExpanded); + } + + ui->overwriteTree->header()->restoreState(s.directInterface() + .value("mod_info_conflicts_general_overwrite").toByteArray()); + + ui->noConflictTree->header()->restoreState(s.directInterface() + .value("mod_info_conflicts_general_noconflict").toByteArray()); + + ui->overwrittenTree->header()->restoreState(s.directInterface() + .value("mod_info_conflicts_general_overwritten").toByteArray()); +} + +bool GeneralConflictsTab::update() +{ + clear(); + + int numNonConflicting = 0; + int numOverwrite = 0; + int numOverwritten = 0; + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + + for (const auto& file : m_tab->origin()->getFiles()) { + // careful: these two strings are moved into createXItem() below + QString relativeName = QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + const auto& alternatives = file->getAlternatives(); + + if (fileOrigin == m_tab->origin()->getID()) { + if (!alternatives.empty()) { + m_overwriteModel->add(createOverwriteItem( + file->getIndex(), archive, + std::move(fileName), std::move(relativeName), alternatives)); + + ++numOverwrite; + } else { + // otherwise, put the file in the noconflict tree + m_noConflictModel->add(createNoConflictItem( + file->getIndex(), archive, + std::move(fileName), std::move(relativeName))); + + ++numNonConflicting; + } + } else { + m_overwrittenModel->add(createOverwrittenItem( + file->getIndex(), fileOrigin, archive, + std::move(fileName), std::move(relativeName))); + + ++numOverwritten; + } + } + + m_overwriteModel->finished(); + m_overwrittenModel->finished(); + m_noConflictModel->finished(); + } + + ui->overwriteCount->display(numOverwrite); + ui->overwrittenCount->display(numOverwritten); + ui->noConflictCount->display(numNonConflicting); + + return (numOverwrite > 0 || numOverwritten > 0); +} + +ConflictItem GeneralConflictsTab::createOverwriteItem( + FileEntry::Index index, bool archive, QString fileName, QString relativeName, + const FileEntry::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + std::wstring altString; + + for (const auto& alt : alternatives) { + if (!altString.empty()) { + altString += L", "; + } + + altString += ds.getOriginByID(alt.first).getName(); + } + + auto origin = ToQString(ds.getOriginByID(alternatives.back().first).getName()); + + return ConflictItem( + ToQString(altString), std::move(relativeName), QString::null, index, + std::move(fileName), true, std::move(origin), archive); +} + +ConflictItem GeneralConflictsTab::createNoConflictItem( + FileEntry::Index index, bool archive, QString fileName, QString relativeName) +{ + return ConflictItem( + QString::null, std::move(relativeName), QString::null, index, + std::move(fileName), false, QString::null, archive); +} + +ConflictItem GeneralConflictsTab::createOverwrittenItem( + FileEntry::Index index, int fileOrigin, bool archive, + QString fileName, QString relativeName) +{ + const auto& ds = *m_core.directoryStructure(); + const FilesOrigin& realOrigin = ds.getOriginByID(fileOrigin); + + QString after = ToQString(realOrigin.getName()); + QString altOrigin = after; + + return ConflictItem( + QString::null, std::move(relativeName), std::move(after), + index, std::move(fileName), true, std::move(altOrigin), archive); +} + +void GeneralConflictsTab::onOverwriteActivated(const QModelIndex& index) +{ + auto* model = dynamic_cast<ConflictListModel*>(ui->overwriteTree->model()); + if (!model) { + return; + } + + auto* item = model->getItem(static_cast<std::size_t>(index.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + +void GeneralConflictsTab::onOverwrittenActivated(const QModelIndex& index) +{ + auto* model = dynamic_cast<ConflictListModel*>(ui->overwrittenTree->model()); + if (!model) { + return; + } + + auto* item = model->getItem(static_cast<std::size_t>(index.row())); + if (!item) { + return; + } + + const auto origin = item->altOrigin(); + if (!origin.isEmpty()) { + emit modOpen(origin); + } +} + + +AdvancedConflictsTab::AdvancedConflictsTab( + ConflictsTab* tab, Ui::ModInfoDialog* pui, OrganizerCore& oc) : + m_tab(tab), ui(pui), m_core(oc), + m_model(new AdvancedConflictListModel(ui->conflictsAdvancedList)) +{ + // left-elide the overwrites column so that the nearest are visible + ui->conflictsAdvancedList->setItemDelegateForColumn( + 0, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // left-elide the file column to see filenames + ui->conflictsAdvancedList->setItemDelegateForColumn( + 1, new ElideLeftDelegate(ui->conflictsAdvancedList)); + + // don't elide the overwritten by column so that the nearest are visible + + QObject::connect( + ui->conflictsAdvancedShowNoConflict, &QCheckBox::clicked, + [&]{ update(); }); + + QObject::connect( + ui->conflictsAdvancedShowAll, &QRadioButton::clicked, + [&]{ update(); }); + + QObject::connect( + ui->conflictsAdvancedShowNearest, &QRadioButton::clicked, + [&]{ update(); }); + + QObject::connect( + ui->conflictsAdvancedList, &QTreeView::customContextMenuRequested, + [&](const QPoint& p){ m_tab->showContextMenu(p, ui->conflictsAdvancedList); }); + + m_filter.setEdit(ui->conflictsAdvancedFilter); + QObject::connect(&m_filter, &FilterWidget::changed, [&]{ update(); }); +} + +void AdvancedConflictsTab::clear() +{ + m_model->clear(); +} + +void AdvancedConflictsTab::saveState(Settings& s) +{ + s.directInterface().setValue( + "mod_info_conflicts_advanced_list", + ui->conflictsAdvancedList->header()->saveState()); + + QByteArray result; + QDataStream stream(&result, QIODevice::WriteOnly); + + stream + << ui->conflictsAdvancedShowNoConflict->isChecked() + << ui->conflictsAdvancedShowAll->isChecked() + << ui->conflictsAdvancedShowNearest->isChecked(); + + s.directInterface().setValue( + "mod_info_conflicts_advanced_options", result); +} + +void AdvancedConflictsTab::restoreState(const Settings& s) +{ + ui->conflictsAdvancedList->header()->restoreState( + s.directInterface().value("mod_info_conflicts_advanced_list").toByteArray()); + + QDataStream stream(s.directInterface() + .value("mod_info_conflicts_advanced_options").toByteArray()); + + bool noConflictChecked = false; + bool showAllChecked = false; + bool showNearestChecked = false; + + stream >> noConflictChecked >> showAllChecked >> showNearestChecked; + + if (stream.status() == QDataStream::Ok) { + ui->conflictsAdvancedShowNoConflict->setChecked(noConflictChecked); + ui->conflictsAdvancedShowAll->setChecked(showAllChecked); + ui->conflictsAdvancedShowNearest->setChecked(showNearestChecked); + } +} + +void AdvancedConflictsTab::update() +{ + clear(); + + if (m_tab->origin() != nullptr) { + const auto rootPath = m_tab->mod().absolutePath(); + + const auto& files = m_tab->origin()->getFiles(); + m_model->reserve(files.size()); + + for (const auto& file : files) { + // careful: these two strings are moved into createItem() below + QString relativeName = QDir::fromNativeSeparators(ToQString(file->getRelativePath())); + QString fileName = rootPath + relativeName; + + bool archive = false; + const int fileOrigin = file->getOrigin(archive); + const auto& alternatives = file->getAlternatives(); + + auto item = createItem( + file->getIndex(), fileOrigin, archive, + std::move(fileName), std::move(relativeName), alternatives); + + if (item) { + m_model->add(std::move(*item)); + } + } + + m_model->finished(); + } +} + +std::optional<ConflictItem> AdvancedConflictsTab::createItem( + FileEntry::Index index, int fileOrigin, bool archive, + QString fileName, QString relativeName, + const MOShared::FileEntry::AlternativesVector& alternatives) +{ + const auto& ds = *m_core.directoryStructure(); + + std::wstring before, after; + + if (!alternatives.empty()) { + const bool showAllAlts = ui->conflictsAdvancedShowAll->isChecked(); + + int beforePrio = 0; + int afterPrio = std::numeric_limits<int>::max(); + + for (const auto& alt : alternatives) + { + const auto& altOrigin = ds.getOriginByID(alt.first); + + if (showAllAlts) { + // fills 'before' and 'after' with all the alternatives that come + // before and after this mod in terms of priority + + if (altOrigin.getPriority() < m_tab->origin()->getPriority()) { + // add all the mods having a lower priority than this one + if (!before.empty()) { + before += L", "; + } + + before += altOrigin.getName(); + } else if (altOrigin.getPriority() > m_tab->origin()->getPriority()) { + // add all the mods having a higher priority than this one + if (!after.empty()) { + after += L", "; + } + + after += altOrigin.getName(); + } + } else { + // keep track of the nearest mods that come before and after this one + // in terms of priority + + if (altOrigin.getPriority() < m_tab->origin()->getPriority()) { + // the alternative has a lower priority than this mod + + if (altOrigin.getPriority() > beforePrio) { + // the alternative has a higher priority and therefore is closer + // to this mod, use it + before = altOrigin.getName(); + beforePrio = altOrigin.getPriority(); + } + } + + if (altOrigin.getPriority() > m_tab->origin()->getPriority()) { + // the alternative has a higher priority than this mod + + if (altOrigin.getPriority() < afterPrio) { + // the alternative has a lower priority and there is closer + // to this mod, use it + after = altOrigin.getName(); + afterPrio = altOrigin.getPriority(); + } + } + } + } + + // the primary origin is never in the list of alternatives, so it has to + // be handled separately + // + // if 'after' is not empty, it means at least one alternative with a higher + // priority than this mod was found; if the user only wants to see the + // nearest mods, it's not worth checking for the primary origin because it + // will always have a higher priority than the alternatives (or it wouldn't + // be the primary) + if (after.empty() || showAllAlts) { + const FilesOrigin& realOrigin = ds.getOriginByID(fileOrigin); + + // if no mods overwrite this file, the primary origin is the same as this + // mod, so ignore that + if (realOrigin.getID() != m_tab->origin()->getID()) { + if (!after.empty()) { + after += L", "; + } + + after += realOrigin.getName(); + } + } + } + + const bool hasAlts = !before.empty() || !after.empty(); + + if (!hasAlts) { + // if both before and after are empty, it means this file has no conflicts + // at all, only display it if the user wants it + if (!ui->conflictsAdvancedShowNoConflict->isChecked()) { + return {}; + } + } + + auto beforeQS = QString::fromStdWString(before); + auto afterQS = QString::fromStdWString(after); + + const bool matched = m_filter.matches([&](auto&& what) { + return + beforeQS.contains(what, Qt::CaseInsensitive) || + relativeName.contains(what, Qt::CaseInsensitive) || + afterQS.contains(what, Qt::CaseInsensitive); + }); + + if (!matched) { + return {}; + } + + return ConflictItem( + std::move(beforeQS), std::move(relativeName), std::move(afterQS), + index, std::move(fileName), hasAlts, QString::null, archive); +} diff --git a/src/modinfodialogconflicts.h b/src/modinfodialogconflicts.h new file mode 100644 index 00000000..a77c2ac9 --- /dev/null +++ b/src/modinfodialogconflicts.h @@ -0,0 +1,142 @@ +#ifndef MODINFODIALOGCONFLICTS_H +#define MODINFODIALOGCONFLICTS_H + +#include "modinfodialogtab.h" +#include "expanderwidget.h" +#include "filterwidget.h" +#include "directoryentry.h" +#include <QTreeWidget> +#include <optional> + +class ConflictsTab; +class OrganizerCore; +class ConflictItem; +class ConflictListModel; + +class GeneralConflictsTab : public QObject +{ + Q_OBJECT; + +public: + GeneralConflictsTab( + ConflictsTab* tab, Ui::ModInfoDialog* ui, OrganizerCore& oc); + + void clear(); + void saveState(Settings& s); + void restoreState(const Settings& s); + + bool update(); + +signals: + void modOpen(QString name); + +private: + struct Expanders + { + ExpanderWidget overwrite, overwritten, nonconflict; + }; + + ConflictsTab* m_tab; + Ui::ModInfoDialog* ui; + OrganizerCore& m_core; + Expanders m_expanders; + + ConflictListModel* m_overwriteModel; + ConflictListModel* m_overwrittenModel; + ConflictListModel* m_noConflictModel; + + ConflictItem createOverwriteItem( + MOShared::FileEntry::Index index, bool archive, + QString fileName, QString relativeName, + const MOShared::FileEntry::AlternativesVector& alternatives); + + ConflictItem createNoConflictItem( + MOShared::FileEntry::Index index, bool archive, + QString fileName, QString relativeName); + + ConflictItem createOverwrittenItem( + MOShared::FileEntry::Index index, int fileOrigin, bool archive, + QString fileName, QString relativeName); + + void onOverwriteActivated(const QModelIndex& index); + void onOverwrittenActivated(const QModelIndex& index); + + void onOverwriteTreeContext(const QPoint &pos); + void onOverwrittenTreeContext(const QPoint &pos); + void onNoConflictTreeContext(const QPoint &pos); +}; + + +class AdvancedConflictsTab : public QObject +{ + Q_OBJECT; + +public: + AdvancedConflictsTab( + ConflictsTab* tab, Ui::ModInfoDialog* ui, OrganizerCore& oc); + + void clear(); + void saveState(Settings& s); + void restoreState(const Settings& s); + + void update(); + +signals: + void modOpen(QString name); + +private: + ConflictsTab* m_tab; + Ui::ModInfoDialog* ui; + OrganizerCore& m_core; + FilterWidget m_filter; + ConflictListModel* m_model; + + std::optional<ConflictItem> createItem( + MOShared::FileEntry::Index index, int fileOrigin, bool archive, + QString fileName, QString relativeName, + const MOShared::FileEntry::AlternativesVector& alternatives); +}; + + +class ConflictsTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + ConflictsTab(ModInfoDialogTabContext cx); + + void update() override; + void clear() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + bool canHandleUnmanaged() const override; + + void openItems(QTreeView* tree); + void previewItems(QTreeView* tree); + void exploreItems(QTreeView* tree); + + void changeItemsVisibility(QTreeView* tree, bool visible); + + void showContextMenu(const QPoint &pos, QTreeView* tree); + +private: + struct Actions + { + QAction* hide = nullptr; + QAction* unhide = nullptr; + QAction* open = nullptr; + QAction* preview = nullptr; + QAction* explore = nullptr; + QMenu* gotoMenu = nullptr; + + std::vector<QAction*> gotoActions; + }; + + GeneralConflictsTab m_general; + AdvancedConflictsTab m_advanced; + + Actions createMenuActions(QTreeView* tree); + std::vector<QAction*> createGotoActions(const ConflictItem* item); +}; + +#endif // MODINFODIALOGCONFLICTS_H diff --git a/src/modinfodialogesps.cpp b/src/modinfodialogesps.cpp new file mode 100644 index 00000000..fba5d39a --- /dev/null +++ b/src/modinfodialogesps.cpp @@ -0,0 +1,399 @@ +#include "modinfodialogesps.h" +#include "ui_modinfodialog.h" +#include "modinfodialog.h" +#include "settings.h" +#include <report.h> + +using MOBase::reportError; + +class ESPItem +{ +public: + ESPItem(QString rootPath, QString relativePath) + : m_rootPath(std::move(rootPath)), m_active(false) + { + if (relativePath.contains('/') || relativePath.contains('\\')) { + m_inactivePath = relativePath; + } else { + m_activePath = relativePath; + m_active = true; + } + + pathChanged(); + } + + const QString& rootPath() const + { + return m_rootPath; + } + + const QString& relativePath() const + { + if (m_active) { + return m_activePath; + } else { + return m_inactivePath; + } + } + + const QString& filename() const + { + return m_filename; + } + + const QString& activePath() const + { + return m_activePath; + } + + const QString& inactivePath() const + { + return m_inactivePath; + } + + const QFileInfo& fileInfo() const + { + return m_fileInfo; + } + + bool isActive() const + { + return m_active; + } + + bool activate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_inactivePath, newName)) { + m_active = true; + m_activePath = newName; + + if (QFileInfo(m_inactivePath).fileName() != newName) { + // file was renamed + m_inactivePath = QFileInfo(m_inactivePath).path() + QDir::separator() + newName; + } + + pathChanged(); + + return true; + } + + return false; + } + + bool deactivate(const QString& newName) + { + QDir root(m_rootPath); + + if (root.rename(m_activePath, newName)) { + m_active = false; + m_inactivePath = newName; + pathChanged(); + return true; + } + + return false; + } + +private: + QString m_rootPath; + QString m_activePath; + QString m_inactivePath; + QString m_filename; + QFileInfo m_fileInfo; + bool m_active; + + void pathChanged() + { + m_fileInfo.setFile(m_rootPath + QDir::separator() + relativePath()); + m_filename = m_fileInfo.fileName(); + } +}; + + +class ESPListModel : public QAbstractItemModel +{ +public: + void clear() + { + m_esps.clear(); + endResetModel(); + } + + QModelIndex index(int row, int col, const QModelIndex& ={}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override + { + return {}; + } + + int rowCount(const QModelIndex& ={}) const override + { + return static_cast<int>(m_esps.size()); + } + + int columnCount(const QModelIndex& ={}) const override + { + return 1; + } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole) { + if (auto* esp=getESP(index)) { + return esp->filename(); + } + } + + return {}; + } + + + void add(ESPItem esp) + { + m_esps.emplace_back(std::move(esp)); + } + + void addOne(ESPItem esp) + { + const auto i = m_esps.size(); + + beginInsertRows({}, static_cast<int>(i), static_cast<int>(i)); + add(std::move(esp)); + endInsertRows(); + } + + bool removeRows(int row, int count, const QModelIndex& = {}) override + { + if (row < 0) { + return false; + } + + const auto start = static_cast<std::size_t>(row); + if (start >= m_esps.size()) { + return false; + } + + const auto end = std::min( + start + static_cast<std::size_t>(count), + m_esps.size()); + + beginRemoveRows({}, static_cast<int>(start), static_cast<int>(end)); + m_esps.erase(m_esps.begin() + start, m_esps.begin() + end); + endRemoveRows(); + + return true; + } + + void finished() + { + std::sort(m_esps.begin(), m_esps.end(), [](const auto& a, const auto& b) { + return (naturalCompare(a.filename(), b.filename()) < 0); + }); + + endResetModel(); + } + + const ESPItem* getESP(const QModelIndex& index) const + { + const auto row = index.row(); + if (row < 0) { + return nullptr; + } + + const auto i = static_cast<std::size_t>(row); + if (i >= m_esps.size()) { + return nullptr; + } + + return &m_esps[i]; + } + + ESPItem* getESP(const QModelIndex& index) + { + return const_cast<ESPItem*>(std::as_const(*this).getESP(index)); + } + +private: + std::deque<ESPItem> m_esps; +}; + + + +ESPsTab::ESPsTab(ModInfoDialogTabContext cx) : + ModInfoDialogTab(std::move(cx)), + m_inactiveModel(new ESPListModel), m_activeModel(new ESPListModel) +{ + ui->inactiveESPList->setModel(m_inactiveModel); + ui->activeESPList->setModel(m_activeModel); + + QObject::connect( + ui->activateESP, &QToolButton::clicked, [&]{ onActivate(); }); + + QObject::connect( + ui->deactivateESP, &QToolButton::clicked, [&]{ onDeactivate(); }); +} + +void ESPsTab::clear() +{ + m_inactiveModel->clear(); + m_activeModel->clear(); + setHasData(false); +} + +bool ESPsTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + static const QString extensions[] = {".esp", ".esm", ".esl"}; + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + ESPItem esp(rootPath, fullPath.mid(rootPath.length() + 1)); + + if (esp.isActive()) { + m_activeModel->add(std::move(esp)); + } else { + m_inactiveModel->add(std::move(esp)); + } + + return true; + } + } + + return false; +} + +void ESPsTab::update() +{ + m_inactiveModel->finished(); + m_activeModel->finished(); + + setHasData(m_inactiveModel->rowCount() > 0 || m_activeModel->rowCount() > 0); +} + +void ESPsTab::saveState(Settings& s) +{ + saveWidgetState(s.directInterface(), ui->ESPsSplitter); +} + +void ESPsTab::restoreState(const Settings& s) +{ + restoreWidgetState(s.directInterface(), ui->ESPsSplitter); +} + +void ESPsTab::onActivate() +{ + const auto index = ui->inactiveESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_inactiveModel->getESP(index); + if (!esp) { + return; + } + + if (esp->isActive()) { + qWarning("ESPsTab::onActive(): item is already active"); + return; + } + + QDir root(esp->rootPath()); + const QFileInfo file(esp->fileInfo()); + + QString newName = file.fileName(); + + while (root.exists(newName)) { + bool okClicked = false; + + newName = QInputDialog::getText( + parentWidget(), + QObject::tr("File Exists"), + QObject::tr("A file with that name exists, please enter a new one"), + QLineEdit::Normal, file.fileName(), &okClicked); + + if (!okClicked) { + return; + } + + if (newName.isEmpty()) { + newName = file.fileName(); + } + } + + if (esp->activate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + m_inactiveModel->removeRow(index.row()); + m_activeModel->addOne(std::move(copy)); + selectRow(ui->inactiveESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::onDeactivate() +{ + const auto index = ui->activeESPList->currentIndex(); + if (!index.isValid()) { + return; + } + + auto* esp = m_activeModel->getESP(index); + if (!esp) { + return; + } + + if (!esp->isActive()) { + qWarning("ESPsTab::onDeactivate(): item is already inactive"); + return; + } + + QDir root(esp->rootPath()); + + // if we moved the file from optional to active in this session, we move the + // file back to where it came from. Otherwise, it is moved to the new folder + // "optional" + + QString newName = esp->inactivePath(); + + if (newName.isEmpty()) { + if (!root.exists("optional")) { + if (!root.mkdir("optional")) { + reportError(QObject::tr("Failed to create directory \"optional\"")); + return; + } + } + + newName = QString("optional") + QDir::separator() + esp->fileInfo().fileName(); + } + + if (esp->deactivate(newName)) { + // copy esp, original will be destroyed + auto copy = *esp; + + m_activeModel->removeRow(index.row()); + m_inactiveModel->addOne(std::move(copy)); + selectRow(ui->activeESPList, index.row()); + } else { + reportError(QObject::tr("Failed to move file")); + } +} + +void ESPsTab::selectRow(QListView* list, int row) +{ + const auto* model = list->model(); + const auto count = model->rowCount(); + if (count == 0) { + return; + } + + if (row >= count) { + list->setCurrentIndex(model->index(count - 1, 0)); + } else { + list->setCurrentIndex(model->index(row, 0)); + } +} diff --git a/src/modinfodialogesps.h b/src/modinfodialogesps.h new file mode 100644 index 00000000..b128f279 --- /dev/null +++ b/src/modinfodialogesps.h @@ -0,0 +1,31 @@ +#ifndef MODINFODIALOGESPS_H +#define MODINFODIALOGESPS_H + +#include "modinfodialogtab.h" + +class ESPItem; +class ESPListModel; + +class ESPsTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + ESPsTab(ModInfoDialogTabContext cx); + + void clear() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update(); + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +private: + ESPListModel* m_inactiveModel; + ESPListModel* m_activeModel; + + void onActivate(); + void onDeactivate(); + void selectRow(QListView* list, int row); +}; + +#endif // MODINFODIALOGESPS_H diff --git a/src/modinfodialogfiletree.cpp b/src/modinfodialogfiletree.cpp new file mode 100644 index 00000000..0b519932 --- /dev/null +++ b/src/modinfodialogfiletree.cpp @@ -0,0 +1,443 @@ +#include "modinfodialogfiletree.h" +#include "ui_modinfodialog.h" +#include "modinfodialog.h" +#include "organizercore.h" +#include "filerenamer.h" +#include <utility.h> +#include <report.h> + +using MOBase::reportError; +namespace shell = MOBase::shell; + +// if there are more than 50 selected items in the filetree, don't bother +// checking whether menu items apply to them, just show all of them +const int max_scan_for_context_menu = 50; + +FileTreeTab::FileTreeTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)), m_fs(nullptr) +{ + m_fs = new QFileSystemModel(this); + m_fs->setReadOnly(false); + ui->filetree->setModel(m_fs); + ui->filetree->setColumnWidth(0, 300); + + m_actions.newFolder = new QAction(tr("&New Folder"), ui->filetree); + m_actions.open = new QAction(tr("&Open/Execute"), ui->filetree); + m_actions.preview = new QAction(tr("&Preview"), ui->filetree); + m_actions.explore = new QAction(tr("Open in &Explorer"), ui->filetree); + m_actions.rename = new QAction(tr("&Rename"), ui->filetree); + m_actions.del = new QAction(tr("&Delete"), ui->filetree); + m_actions.hide = new QAction(tr("&Hide"), ui->filetree); + m_actions.unhide = new QAction(tr("&Unhide"), ui->filetree); + + connect(m_actions.newFolder, &QAction::triggered, [&]{ onCreateDirectory(); }); + connect(m_actions.open, &QAction::triggered, [&]{ onOpen(); }); + connect(m_actions.preview, &QAction::triggered, [&]{ onPreview(); }); + connect(m_actions.explore, &QAction::triggered, [&]{ onExplore(); }); + connect(m_actions.rename, &QAction::triggered, [&]{ onRename(); }); + connect(m_actions.del, &QAction::triggered, [&]{ onDelete(); }); + connect(m_actions.hide, &QAction::triggered, [&]{ onHide(); }); + connect(m_actions.unhide, &QAction::triggered, [&]{ onUnhide(); }); + + connect(ui->openInExplorer, &QToolButton::clicked, [&]{ onOpenInExplorer(); }); + + connect( + ui->filetree, &QTreeView::customContextMenuRequested, + [&](const QPoint& pos){ onContextMenu(pos); }); +} + +void FileTreeTab::clear() +{ + m_fs->setRootPath({}); + + // always has data; even if the mod is empty, it still has a meta.ini + setHasData(true); +} + +void FileTreeTab::update() +{ + const auto rootPath = mod().absolutePath(); + + m_fs->setRootPath(rootPath); + ui->filetree->setRootIndex(m_fs->index(rootPath)); +} + +bool FileTreeTab::deleteRequested() +{ + if (!ui->filetree->hasFocus()) { + return false; + } + + onDelete(); + return true; +} + +QModelIndex FileTreeTab::singleSelection() const +{ + const auto rows = ui->filetree->selectionModel()->selectedRows(); + if (rows.size() != 1) { + return {}; + } + + return rows[0]; +} + +void FileTreeTab::onCreateDirectory() +{ + const auto selectedRows = ui->filetree->selectionModel()->selectedRows(); + if (selectedRows.size() > 1) { + return; + } + + QModelIndex selection; + + if (selectedRows.size() == 0) { + selection = m_fs->index(m_fs->rootPath(), 0); + } else { + selection = selectedRows[0]; + } + + QModelIndex index = m_fs->isDir(selection) ? selection : selection.parent(); + index = index.sibling(index.row(), 0); + + QString name = tr("New Folder"); + QString path = m_fs->filePath(index).append("/"); + + QModelIndex existingIndex = m_fs->index(path + name); + int suffix = 1; + while (existingIndex.isValid()) { + name = tr("New Folder") + QString::number(suffix++); + existingIndex = m_fs->index(path + name); + } + + QModelIndex newIndex = m_fs->mkdir(index, name); + if (!newIndex.isValid()) { + reportError(tr("Failed to create \"%1\"").arg(name)); + return; + } + + ui->filetree->setCurrentIndex(newIndex); + ui->filetree->edit(newIndex); +} + +void FileTreeTab::onOpen() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + shell::OpenFile(m_fs->filePath(selection)); +} + +void FileTreeTab::onPreview() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + core().previewFile(parentWidget(), mod().name(), m_fs->filePath(selection)); +} + +void FileTreeTab::onExplore() +{ + auto selection = singleSelection(); + + if (selection.isValid()) { + shell::ExploreFile(m_fs->filePath(selection)); + } else { + shell::ExploreFile(mod().absolutePath()); + } +} + +void FileTreeTab::onRename() +{ + auto selection = singleSelection(); + if (!selection.isValid()) { + return; + } + + QModelIndex index = selection.sibling(selection.row(), 0); + if (!index.isValid() || m_fs->isReadOnly()) { + return; + } + + ui->filetree->edit(index); +} + +void FileTreeTab::onDelete() +{ + const auto rows = ui->filetree->selectionModel()->selectedRows(); + if (rows.count() == 0) { + return; + } + + QString message; + + if (rows.count() == 1) { + QString fileName = m_fs->fileName(rows[0]); + message = tr("Are you sure you want to delete \"%1\"?").arg(fileName); + } else { + message = tr("Are you sure you want to delete the selected files?"); + } + + if (QMessageBox::question(parentWidget(), tr("Confirm"), message) != QMessageBox::Yes) { + return; + } + + for (const auto& index : rows) { + deleteFile(index); + } +} + +void FileTreeTab::onHide() +{ + changeVisibility(false); +} + +void FileTreeTab::onUnhide() +{ + changeVisibility(true); +} + +void FileTreeTab::onOpenInExplorer() +{ + shell::ExploreFile(mod().absolutePath()); +} + +bool FileTreeTab::deleteFile(const QModelIndex& index) +{ + bool res = false; + + if (m_fs->isDir(index)) { + res = deleteFileRecursive(index); + } else { + res = m_fs->remove(index); + } + + if (!res) { + reportError(tr("Failed to delete %1").arg(m_fs->fileName(index))); + } + + return res; +} + +bool FileTreeTab::deleteFileRecursive(const QModelIndex& parent) +{ + for (int row = 0; row<m_fs->rowCount(parent); ++row) { + QModelIndex index = m_fs->index(row, 0, parent); + + if (m_fs->isDir(index)) { + if (!deleteFileRecursive(index)) { + qCritical() << "failed to delete" << m_fs->fileName(index); + return false; + } + } else { + if (!m_fs->remove(index)) { + qCritical() << "failed to delete", m_fs->fileName(index); + return false; + } + } + } + + if (!m_fs->remove(parent)) { + qCritical() << "failed to delete" << m_fs->fileName(parent); + return false; + } + + return true; +} + +void FileTreeTab::changeVisibility(bool visible) +{ + const auto selection = ui->filetree->selectionModel()->selectedRows(); + + bool changed = false; + bool stop = false; + + qDebug().nospace() + << (visible ? "unhiding" : "hiding") << " " + << selection.size() << " filetree files"; + + QFlags<FileRenamer::RenameFlags> flags = + (visible ? FileRenamer::UNHIDE : FileRenamer::HIDE); + + if (selection.size() > 1) { + flags |= FileRenamer::MULTIPLE; + } + + FileRenamer renamer(parentWidget(), flags); + + for (const auto& index : selection) { + if (stop) { + break; + } + + const QString path = m_fs->filePath(index); + auto result = FileRenamer::RESULT_CANCEL; + + if (visible) { + if (!canUnhideFile(false, path)) { + qDebug().nospace() << "cannot unhide " << path << ", skipping"; + continue; + } + result = unhideFile(renamer, path); + } else { + if (!canHideFile(false, path)) { + qDebug().nospace() << "cannot hide " << path << ", skipping"; + continue; + } + result = hideFile(renamer, path); + } + + switch (result) { + case FileRenamer::RESULT_OK: { + // will trigger a refresh at the end + changed = true; + break; + } + + case FileRenamer::RESULT_SKIP: { + // nop + break; + } + + case FileRenamer::RESULT_CANCEL: { + // stop right now, but make sure to refresh if needed + stop = true; + break; + } + } + } + + qDebug().nospace() << (visible ? "unhiding" : "hiding") << " filetree files done"; + + if (changed) { + if (origin()) { + emitOriginModified(); + } + } +} + +void FileTreeTab::onContextMenu(const QPoint &pos) +{ + const auto selection = ui->filetree->selectionModel()->selectedRows(); + + QMenu menu(ui->filetree); + + bool enableNewFolder = true; + bool enableOpen = true; + bool enablePreview = true; + bool enableExplore = true; + bool enableRename = true; + bool enableDelete = true; + bool enableHide = true; + bool enableUnhide = true; + + if (selection.size() == 0) { + // no selection, only new folder and explore + enableOpen = false; + enablePreview = false; + enableRename = false; + enableDelete = false; + enableHide = false; + enableUnhide = false; + } else if (selection.size() == 1) { + // single selection + + // only enable open action if a file is selected + bool hasFiles = false; + + for (auto index : selection) { + if (m_fs->fileInfo(index).isFile()) { + hasFiles = true; + break; + } + } + + if (!hasFiles) { + enableOpen = false; + enablePreview = false; + } + + const QString fileName = m_fs->fileName(selection[0]); + + if (!canPreviewFile(plugin(), false, fileName)) { + enablePreview = false; + } + + if (!canExploreFile(false, fileName)) { + enableExplore = false; + } + + if (!canHideFile(false, fileName)) { + enableHide = false; + } + + if (!canUnhideFile(false, fileName)) { + enableUnhide = false; + } + } else { + // this is a multiple selection, don't show open action so users don't open + // a thousand files + enableOpen = false; + enablePreview = false; + + // can't explore multiple files + enableExplore = false; + + // can't rename multiple files + enableRename = false; + + if (selection.size() < max_scan_for_context_menu) { + // if the number of selected items is low, checking them to accurately + // show the menu items is worth it + enableHide = false; + enableUnhide = false; + + for (const auto& index : selection) { + const QString fileName = m_fs->fileName(index); + + if (canHideFile(false, fileName)) { + enableHide = true; + } + + if (canUnhideFile(false, fileName)) { + enableUnhide = true; + } + + if (enableHide && enableUnhide) { + // found both, no need to check more + break; + } + } + } + } + + menu.addAction(m_actions.newFolder); + m_actions.newFolder->setEnabled(enableNewFolder); + + menu.addAction(m_actions.open); + m_actions.open->setEnabled(enableOpen); + + menu.addAction(m_actions.preview); + m_actions.preview->setEnabled(enablePreview); + + menu.addAction(m_actions.explore); + m_actions.explore->setEnabled(enableExplore); + + menu.addAction(m_actions.rename); + m_actions.rename->setEnabled(enableRename); + + menu.addAction(m_actions.del); + m_actions.del->setEnabled(enableDelete); + + menu.addAction(m_actions.hide); + m_actions.hide->setEnabled(enableHide); + + menu.addAction(m_actions.unhide); + m_actions.unhide->setEnabled(enableUnhide); + + menu.exec(ui->filetree->viewport()->mapToGlobal(pos)); +} diff --git a/src/modinfodialogfiletree.h b/src/modinfodialogfiletree.h new file mode 100644 index 00000000..f9fa62d4 --- /dev/null +++ b/src/modinfodialogfiletree.h @@ -0,0 +1,48 @@ +#ifndef MODINFODIALOGFILETREE_H +#define MODINFODIALOGFILETREE_H + +#include "modinfodialogtab.h" + +class FileTreeTab : public ModInfoDialogTab +{ +public: + FileTreeTab(ModInfoDialogTabContext cx); + + void clear() override; + void update() override; + bool deleteRequested() override; + +private: + struct Actions + { + QAction *newFolder = nullptr; + QAction *open = nullptr; + QAction *preview = nullptr; + QAction *explore = nullptr; + QAction *rename = nullptr; + QAction *del = nullptr; + QAction *hide = nullptr; + QAction *unhide = nullptr; + }; + + QFileSystemModel* m_fs; + Actions m_actions; + + void onCreateDirectory(); + void onOpen(); + void onPreview(); + void onExplore(); + void onRename(); + void onDelete(); + void onHide(); + void onUnhide(); + void onOpenInExplorer(); + void onContextMenu(const QPoint &pos); + + QModelIndex singleSelection() const; + bool deleteFile(const QModelIndex& index); + bool deleteFileRecursive(const QModelIndex& index); + void changeVisibility(bool visible); +}; + +#endif // MODINFODIALOGFILETREE_H diff --git a/src/modinfodialogfwd.h b/src/modinfodialogfwd.h new file mode 100644 index 00000000..9ede766f --- /dev/null +++ b/src/modinfodialogfwd.h @@ -0,0 +1,50 @@ +#ifndef MODINFODIALOGFWD_H +#define MODINFODIALOGFWD_H + +#include "filerenamer.h" + +class ModInfo; +using ModInfoPtr = QSharedPointer<ModInfo>; + +enum class ModInfoTabIDs +{ + None = -1, + TextFiles = 0, + IniFiles, + Images, + Esps, + Conflicts, + Categories, + Nexus, + Notes, + Filetree +}; + +class PluginContainer; + +bool canPreviewFile(PluginContainer& pluginContainer, bool isArchive, const QString& filename); +bool canOpenFile(bool isArchive, const QString& filename); +bool canExploreFile(bool isArchive, const QString& filename); +bool canHideFile(bool isArchive, const QString& filename); +bool canUnhideFile(bool isArchive, const QString& filename); + +FileRenamer::RenameResults hideFile(FileRenamer& renamer, const QString &oldName); +FileRenamer::RenameResults unhideFile(FileRenamer& renamer, const QString &oldName); + +int naturalCompare(const QString& a, const QString& b); + + +class ElideLeftDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + +protected: + void initStyleOption(QStyleOptionViewItem* o, const QModelIndex& i) const + { + QStyledItemDelegate::initStyleOption(o, i); + o->textElideMode = Qt::ElideLeft; + } +}; + +#endif // MODINFODIALOGFWD_H diff --git a/src/modinfodialogimages.cpp b/src/modinfodialogimages.cpp new file mode 100644 index 00000000..69866902 --- /dev/null +++ b/src/modinfodialogimages.cpp @@ -0,0 +1,1102 @@ +#include "modinfodialogimages.h" +#include "ui_modinfodialog.h" +#include "settings.h" +#include "utility.h" + +using namespace ImagesTabHelpers; + +QSize resizeWithAspectRatio(const QSize& original, const QSize& available) +{ + const auto ratio = std::min({ + 1.0, + static_cast<double>(available.width()) / original.width(), + static_cast<double>(available.height()) / original.height()}); + + const QSize scaledSize( + static_cast<int>(std::round(original.width() * ratio)), + static_cast<int>(std::round(original.height() * ratio))); + + return scaledSize; +} + +QRect centeredRect(const QRect& rect, const QSize& size) +{ + return QRect( + (rect.left()+rect.width()/2) - size.width()/2, + (rect.top()+rect.height()/2) - size.height()/2, + size.width(), + size.height()); +} + +QString dimensionString(const QSize& s) +{ + return QString::fromUtf8("%1 \xc3\x97 %2") + .arg(s.width()).arg(s.height()); +} + + +ImagesTab::ImagesTab(ModInfoDialogTabContext cx) : + ModInfoDialogTab(std::move(cx)), m_image(new ScalableImage), + m_ddsAvailable(false), m_ddsEnabled(false) +{ + getSupportedFormats(); + + auto* ly = new QVBoxLayout(ui->imagesImage); + ly->setContentsMargins({0, 0, 0, 0}); + ly->addWidget(m_image); + + delete ui->imagesThumbnails->layout(); + + ui->tabImagesSplitter->setSizes({128, 1}); + ui->tabImagesSplitter->setStretchFactor(0, 0); + ui->tabImagesSplitter->setStretchFactor(1, 1); + + ui->imagesThumbnails->setTab(this); + + ui->imagesScrollerVBar->setTab(this); + connect(ui->imagesScrollerVBar, &QScrollBar::valueChanged, [&]{ onScrolled(); }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + m_filter.setEdit(ui->imagesFilter); + connect(&m_filter, &FilterWidget::changed, [&]{ onFilterChanged(); }); + + connect(ui->imagesExplore, &QAbstractButton::clicked, [&]{ onExplore(); }); + connect(ui->imagesShowDDS, &QCheckBox::toggled, [&]{ onShowDDS(); }); + + ui->imagesShowDDS->setEnabled(m_ddsAvailable); + + ui->imagesThumbnails->setAutoFillBackground(false); + ui->imagesThumbnails->setAttribute(Qt::WA_OpaquePaintEvent, true); + + { + auto list = std::make_unique<QListWidget>(); + parentWidget()->style()->polish(list.get()); + + m_theme.borderColor = QColor(Qt::black); + m_theme.backgroundColor = QColor(Qt::black); + m_theme.textColor = list->palette().color(QPalette::WindowText); + + m_theme.highlightBackgroundColor = list->palette().color(QPalette::Highlight); + m_theme.highlightTextColor = list->palette().color(QPalette::HighlightedText); + + m_theme.font = list->font(); + + const QFontMetrics fm(m_theme.font); + m_metrics.textHeight = fm.height(); + + m_image->setColors(m_theme.borderColor, m_theme.backgroundColor); + } +} + +void ImagesTab::clear() +{ + m_files.clear(); + ui->imagesScrollerVBar->setValue(0); + select(BadIndex); + setHasData(false); +} + +bool ImagesTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + for (const auto& ext : m_supportedFormats) { + if (fullPath.endsWith(ext, Qt::CaseInsensitive)) { + m_files.add({fullPath}); + return true; + } + } + + return false; +} + +void ImagesTab::update() +{ + checkFiltering(); + updateScrollbar(); + + // visibility needs to be rechecked here because the scrollbar configuration + // may have changed in updateScrollbar(), in which case any ensureVisible() + // calls in checkFiltering() might have been incorrect + if (m_files.selectedIndex() != BadIndex) { + ensureVisible(m_files.selectedIndex(), Visibility::Partial); + } + + ui->imagesThumbnails->update(); + + setHasData(m_files.size() > 0); +} + +void ImagesTab::saveState(Settings& s) +{ + s.directInterface().setValue( + "mod_info_dialog_images_show_dds", m_ddsEnabled); + + saveWidgetState(s.directInterface(), ui->tabImagesSplitter); +} + +void ImagesTab::restoreState(const Settings& s) +{ + ui->imagesShowDDS->setChecked(s.directInterface() + .value("mod_info_dialog_images_show_dds", false).toBool()); + + restoreWidgetState(s.directInterface(), ui->tabImagesSplitter); +} + +void ImagesTab::checkFiltering() +{ + if (m_filter.empty() && m_ddsEnabled) { + // no filtering needed + + if (m_files.isFiltered()) { + // was filtered, needs switch + switchToAll(); + } + } else { + // filtering is needed + switchToFiltered(); + } +} + +void ImagesTab::switchToAll() +{ + // remember selection + const auto* oldSelection = m_files.selectedFile(); + + // switch + m_files.switchToAll(); + + // reselect old + if (oldSelection) { + select(m_files.indexOf(oldSelection)); + } else { + select(BadIndex); + } +} + +void ImagesTab::switchToFiltered() +{ + // remember old selection, will be checked when building the filtered list + // below + const auto* oldSelection = m_files.selectedFile(); + std::size_t newSelection = BadIndex; + + // switch, also clears list + m_files.switchToFiltered(); + + const bool hasTextFilter = !m_filter.empty(); + + for (auto& f : m_files.allFiles()) { + if (hasTextFilter) { + // check filter widget + const auto m = m_filter.matches([&](auto&& what) { + return f.path().contains(what, Qt::CaseInsensitive); + }); + + if (!m) { + // no match, skip + continue; + } + } + + if (!m_ddsEnabled) { + // skip .dds files + if (f.path().endsWith(".dds", Qt::CaseInsensitive)) { + continue; + } + } + + if (&f == oldSelection) { + // found the old selection, remember its index + newSelection = m_files.size(); + } + + m_files.addFiltered(&f); + } + + // reselect old, or clear if it wasn't found + select(newSelection); +} + +void ImagesTab::getSupportedFormats() +{ + m_ddsAvailable = false; + + for (const auto& entry : QImageReader::supportedImageFormats()) { + QString s(entry); + if (s.isNull() || s.isEmpty()) { + continue; + } + + // used to enable the checkbox + if (s.compare("dds", Qt::CaseInsensitive) == 0) { + m_ddsAvailable = true; + } + + // make sure it starts with a dot + if (s[0] != ".") { + s = "." + s; + } + + m_supportedFormats.emplace_back(std::move(s)); + } +} + +void ImagesTab::select(std::size_t i, Visibility v) +{ + m_files.select(i); + + if (auto* f=m_files.selectedFile()) { + // when jumping elsewhere in the list, such as with page down/up, the file + // might not be visible yet, which means it hasn't been loaded and would + // pass a null image in setImage() below + f->ensureOriginalLoaded(); + + ui->imagesPath->setText(QDir::toNativeSeparators(f->path())); + ui->imagesExplore->setEnabled(true); + ui->imagesSize->setText(dimensionString(f->original().size())); + + m_image->setImage(f->original()); + ensureVisible(i, v); + } else { + ui->imagesPath->clear(); + ui->imagesExplore->setEnabled(false); + ui->imagesSize->clear(); + m_image->clear(); + } + + ui->imagesThumbnails->update(); +} + +void ImagesTab::moveSelection(int by) +{ + if (m_files.empty()) { + return; + } + + auto i = m_files.selectedIndex(); + if (i == BadIndex) { + i = 0; + } + + if (by > 0) { + // moving down + i += static_cast<std::size_t>(by); + + if (i >= m_files.size()) { + i = (m_files.size() - 1); + } + } else if (by < 0) { + // moving up + const auto abs_by = static_cast<std::size_t>(std::abs(by)); + + if (abs_by > i) { + i = 0; + } else { + i -= abs_by; + } + } + + select(i); +} + +void ImagesTab::ensureVisible(std::size_t i, Visibility v) +{ + if (v == Visibility::Ignore) { + return; + } + + const auto geo = makeGeometry(); + + const auto fullyVisible = geo.fullyVisibleCount(); + const auto partiallyVisible = fullyVisible + 1; + + const auto first = ui->imagesScrollerVBar->value(); + const auto last = (v == Visibility::Full ? + first + fullyVisible : first + partiallyVisible); + + if (i < first) { + // go up + ui->imagesScrollerVBar->setValue(static_cast<int>(i)); + } else if (i >= last) { + // go down + + if (i >= fullyVisible) { + ui->imagesScrollerVBar->setValue(static_cast<int>(i - fullyVisible + 1)); + } + } +} + +std::size_t ImagesTab::fileIndexAtPos(const QPoint& p) const +{ + const auto geo = makeGeometry(); + + // this is the index relative to the top + const auto offset = geo.indexAt(p); + if (offset == BadIndex) { + return BadIndex; + } + + const auto first = ui->imagesScrollerVBar->value(); + if (first < 0) { + return BadIndex; + } + + const auto i = static_cast<std::size_t>(first) + offset; + if (i >= m_files.size()) { + return BadIndex; + } + + return i; +} + +const File* ImagesTab::fileAtPos(const QPoint& p) const +{ + const auto i = fileIndexAtPos(p); + if (i >= m_files.size()) { + return nullptr; + } + + return m_files.get(i); +} + +Geometry ImagesTab::makeGeometry() const +{ + return Geometry(ui->imagesThumbnails->size(), m_metrics); +} + +void ImagesTab::paintThumbnailsArea(QPaintEvent* e) +{ + PaintContext cx(ui->imagesThumbnails, makeGeometry()); + + cx.painter.fillRect( + ui->imagesThumbnails->rect(), + ui->imagesThumbnails->palette().color(QPalette::Window)); + + const auto visible = cx.geo.fullyVisibleCount() + 1; + const auto first = ui->imagesScrollerVBar->value(); + + for (std::size_t i=0; i<visible; ++i) { + const auto fileIndex = first + i; + auto* file = m_files.get(fileIndex); + if (!file) { + break; + } + + cx.file = file; + cx.thumbIndex = i; + cx.fileIndex = fileIndex; + + paintThumbnail(cx); + } +} + +void ImagesTab::paintThumbnail(const PaintContext& cx) +{ + paintThumbnailBackground(cx); + paintThumbnailBorder(cx); + paintThumbnailImage(cx); + paintThumbnailText(cx); +} + +void ImagesTab::paintThumbnailBackground(const PaintContext& cx) +{ + if (m_files.selectedIndex() == cx.fileIndex) { + const auto rect = cx.geo.thumbRect(cx.thumbIndex); + cx.painter.fillRect(rect, m_theme.highlightBackgroundColor); + } +} + +void ImagesTab::paintThumbnailBorder(const PaintContext& cx) +{ + auto borderRect = cx.geo.borderRect(cx.thumbIndex); + + // rects don't include the bottom right corner, but drawRect() does, so + // resize it + borderRect.setRight(borderRect.right() - 1); + borderRect.setBottom(borderRect.bottom() - 1); + + cx.painter.setPen(m_theme.borderColor); + cx.painter.drawRect(borderRect); +} + +void ImagesTab::paintThumbnailImage(const PaintContext& cx) +{ + if (cx.file->failed()) { + return; + } + + cx.file->loadIfNeeded(cx.geo); + + const auto imageRect = cx.geo.imageRect(cx.thumbIndex); + const auto scaledThumbRect = centeredRect( + imageRect, cx.file->thumbnail().size()); + + cx.painter.fillRect(scaledThumbRect, m_theme.backgroundColor); + cx.painter.drawImage(scaledThumbRect, cx.file->thumbnail()); +} + +void ImagesTab::paintThumbnailText(const PaintContext& cx) +{ + const auto tr = cx.geo.textRect(cx.thumbIndex); + + if (cx.fileIndex == m_files.selectedIndex()) { + cx.painter.setPen(m_theme.highlightTextColor); + } else { + cx.painter.setPen(m_theme.textColor); + } + + cx.painter.setFont(m_theme.font); + + QFontMetrics fm(m_theme.font); + + const auto text = fm.elidedText( + cx.file->filename(), Qt::ElideRight, tr.width()); + + const auto flags = Qt::AlignHCenter | Qt::AlignVCenter | Qt::TextSingleLine; + + cx.painter.drawText(tr, flags, text); +} + +void ImagesTab::scrollAreaResized(const QSize&) +{ + updateScrollbar(); +} + +void ImagesTab::thumbnailAreaMouseEvent(QMouseEvent* e) +{ + if (e->button() != Qt::LeftButton) { + return; + } + + const auto i = fileIndexAtPos(e->pos()); + if (i != BadIndex) { + // the only way to click on a thumbnail is if it's already visible, so the + // only thing that can happen is a click on a partially visible thumbnail, + // which would scroll so it is fully visible, and that's just annoying + select(i, Visibility::Ignore); + } +} + +void ImagesTab::thumbnailAreaWheelEvent(QWheelEvent* e) +{ + const auto d = (e->angleDelta() / 8).y(); + + ui->imagesScrollerVBar->setValue( + ui->imagesScrollerVBar->value() + (d > 0 ? -1 : 1)); +} + +bool ImagesTab::thumbnailAreaKeyPressEvent(QKeyEvent* e) +{ + switch (e->key()) { + case Qt::Key_Down: { + moveSelection(ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_Up: { + moveSelection(-ui->imagesScrollerVBar->singleStep()); + return true; + } + + case Qt::Key_PageDown: { + moveSelection(ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_PageUp: { + moveSelection(-ui->imagesScrollerVBar->pageStep()); + return true; + } + + case Qt::Key_Home: { + select(0); + return true; + } + + case Qt::Key_End: { + if (!m_files.empty()) { + select(m_files.size() - 1); + } + + return true; + } + } + + return false; +} + +void ImagesTab::onScrolled() +{ + ui->imagesThumbnails->update(); +} + +void ImagesTab::showTooltip(QHelpEvent* e) +{ + const auto* f = fileAtPos(e->pos()); + if (!f) { + QToolTip::hideText(); + e->ignore(); + return; + } + + const auto s = QString("%1 (%2)") + .arg(QDir::toNativeSeparators(f->path())) + .arg(dimensionString(f->original().size())); + + QToolTip::showText(e->globalPos(), s, ui->imagesThumbnails); +} + +void ImagesTab::onExplore() +{ + if (auto* f=m_files.selectedFile()) { + MOBase::shell::ExploreFile(f->path()); + } +} + +void ImagesTab::onShowDDS() +{ + const auto b = ui->imagesShowDDS->isChecked(); + if (b != m_ddsEnabled) { + m_ddsEnabled = b; + update(); + } +} + +void ImagesTab::onFilterChanged() +{ + update(); +} + +void ImagesTab::updateScrollbar() +{ + if (m_files.size() == 0) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + return; + } + + const auto geo = makeGeometry(); + const auto availableSize = ui->imagesThumbnails->size(); + const auto fullyVisible = geo.fullyVisibleCount(); + + if (fullyVisible >= m_files.size()) { + ui->imagesScrollerVBar->setRange(0, 0); + ui->imagesScrollerVBar->setEnabled(false); + } else { + const auto d = m_files.size() - fullyVisible; + ui->imagesScrollerVBar->setRange(0, static_cast<int>(d)); + ui->imagesScrollerVBar->setSingleStep(1); + ui->imagesScrollerVBar->setPageStep(static_cast<int>(fullyVisible - 1)); + ui->imagesScrollerVBar->setEnabled(true); + } +} + + +namespace ImagesTabHelpers +{ + +void Scrollbar::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void Scrollbar::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + + +void ThumbnailsWidget::setTab(ImagesTab* tab) +{ + m_tab = tab; +} + +void ThumbnailsWidget::paintEvent(QPaintEvent* e) +{ + if (m_tab) { + m_tab->paintThumbnailsArea(e); + } +} + +void ThumbnailsWidget::mousePressEvent(QMouseEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaMouseEvent(e); + } +} + +void ThumbnailsWidget::wheelEvent(QWheelEvent* e) +{ + if (m_tab) { + m_tab->thumbnailAreaWheelEvent(e); + } +} + +void ThumbnailsWidget::resizeEvent(QResizeEvent* e) +{ + if (m_tab) { + m_tab->scrollAreaResized(e->size()); + } +} + +void ThumbnailsWidget::keyPressEvent(QKeyEvent* e) +{ + if (m_tab) { + if (m_tab->thumbnailAreaKeyPressEvent(e)) { + return; + } + } + + QWidget::keyPressEvent(e); +} + +bool ThumbnailsWidget::event(QEvent* e) +{ + if (e->type() == QEvent::ToolTip) { + m_tab->showTooltip(static_cast<QHelpEvent*>(e)); + return true; + } + + return QWidget::event(e); +} + + +ScalableImage::ScalableImage(QString path) + : m_path(std::move(path)), m_border(1) +{ + auto sp = sizePolicy(); + sp.setHeightForWidth(true); + setSizePolicy(sp); +} + +void ScalableImage::setImage(const QString& path) +{ + m_path = path; + m_original = {}; + m_scaled = {}; + + update(); +} + +void ScalableImage::setImage(QImage image) +{ + m_path.clear(); + m_original = std::move(image); + m_scaled = {}; + + update(); +} + +void ScalableImage::clear() +{ + setImage(QImage()); +} + +bool ScalableImage::hasHeightForWidth() const +{ + return true; +} + +int ScalableImage::heightForWidth(int w) const +{ + return w; +} + +void ScalableImage::setColors(const QColor& border, const QColor& background) +{ + m_borderColor = border; + m_backgroundColor = background; +} + +void ScalableImage::paintEvent(QPaintEvent* e) +{ + if (m_original.isNull()) { + if (m_path.isNull()) { + return; + } + + m_original.load(m_path); + + if (m_original.isNull()) { + return; + } + } + + const QRect widgetRect = rect(); + const QRect imageRect = widgetRect.adjusted( + m_border, m_border, -m_border, -m_border); + + const QSize scaledSize = resizeWithAspectRatio( + m_original.size(), imageRect.size()); + + if (m_scaled.isNull() || m_scaled.size() != scaledSize) { + m_scaled = m_original.scaled( + scaledSize.width(), scaledSize.height(), + Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + } + + const QRect drawBorderRect = widgetRect.adjusted(0, 0, -1, -1); + const QRect drawImageRect = centeredRect(imageRect, m_scaled.size()); + + QPainter painter(this); + + // border + painter.setPen(m_borderColor); + painter.drawRect(drawBorderRect); + + // background + painter.fillRect(drawImageRect, m_backgroundColor); + + // image + painter.drawImage(drawImageRect, m_scaled); +} + + +Metrics::Metrics() : + margins(3), + border(1), + padding(0), + spacing(5), + textSpacing(2), + textHeight(0) +{ +} + + +Geometry::Geometry(QSize widgetSize, Metrics metrics) + : m_widgetSize(widgetSize), m_metrics(metrics), m_topRect(calcTopRect()) +{ +} + +QRect Geometry::calcTopRect() const +{ + const auto thumbWidth = m_widgetSize.width(); + const auto& m = m_metrics; + + const auto imageSize = + thumbWidth - + (m.margins * 2) - + (m.border * 2) - + (m.padding * 2); + + const auto thumbHeight = + m.margins + + m.border + m.padding + + imageSize + + m.padding + m.border + + m.textSpacing + m.textHeight + + m.margins; + + return {0, 0, thumbWidth, thumbHeight}; +} + +std::size_t Geometry::fullyVisibleCount() const +{ + const auto r = thumbRect(0); + + const auto thumbWithSpacing = r.height() + m_metrics.spacing; + const auto visible = (m_widgetSize.height() / thumbWithSpacing); + + return static_cast<std::size_t>(visible); +} + +QRect Geometry::thumbRect(std::size_t i) const +{ + // rect for the top thumbnail + QRect r = m_topRect; + + // move down + const auto thumbWithSpacing = m_metrics.spacing + r.height(); + r.translate(0, static_cast<int>(i * thumbWithSpacing)); + + return r; +} + +QRect Geometry::borderRect(std::size_t i) const +{ + auto r = thumbRect(i); + const auto& m = m_metrics; + + // remove margins and text + r.adjust(m.margins, m.margins, -m.margins, -m.margins); + + // remove text + r.adjust(0, 0, 0, -(m.textSpacing + m.textHeight)); + + return r; +} + +QRect Geometry::imageRect(std::size_t i) const +{ + auto r = borderRect(i); + + // remove border and padding + const auto m = m_metrics.border + m_metrics.padding; + r.adjust(m, m, -m, -m); + + return r; +} + +QRect Geometry::textRect(std::size_t i) const +{ + const auto r = borderRect(i); + + return QRect( + r.left(), + r.bottom() + m_metrics.textSpacing, + r.width(), + m_metrics.textHeight); +} + +QRect Geometry::selectionRect(std::size_t i) const +{ + const auto br = borderRect(i); + const auto tr = textRect(i); + + return QRect( + br.left(), + br.top(), + br.width(), + tr.bottom() - br.top()); +} + +std::size_t Geometry::indexAt(const QPoint& p) const +{ + // calculate index purely based on y position + const std::size_t offset = p.y() / (m_topRect.height() + m_metrics.spacing); + + if (!selectionRect(offset).contains(p)) { + return BadIndex; + } + + return offset; +} + +QSize Geometry::scaledImageSize(const QSize& originalSize) const +{ + const auto availableSize = imageRect(0).size(); + return resizeWithAspectRatio(originalSize, availableSize); +} + + +File::File(QString path) + : m_path(std::move(path)), m_failed(false) +{ +} + +void File::ensureOriginalLoaded() +{ + if (!m_original.isNull()) { + // already loaded + return; + } + + QImageReader reader(m_path); + + if (!reader.read(&m_original)) { + qCritical().noquote().nospace() + << "failed to load '" << m_path << "'\n" + << reader.errorString() << " " + << "(error " << static_cast<int>(reader.error()) << ")"; + + m_failed = true; + } +} + +const QString& File::path() const +{ + return m_path; +} + +const QString& File::filename() const +{ + if (m_filename.isEmpty()) { + m_filename = QFileInfo(m_path).fileName(); + } + + return m_filename; +} + +const QImage& File::original() const +{ + return m_original; +} + +const QImage& File::thumbnail() const +{ + return m_thumbnail; +} + +bool File::failed() const +{ + return m_failed; +} + +void File::loadIfNeeded(const Geometry& geo) +{ + if (needsLoad(geo)) { + load(geo); + } +} + +bool File::needsLoad(const Geometry& geo) const +{ + if (m_failed) { + return false; + } + + if (m_original.isNull() || m_thumbnail.isNull()) { + return true; + } + + const auto scaledSize = geo.scaledImageSize(m_original.size()); + return (m_thumbnail.size() != scaledSize); +} + +void File::load(const Geometry& geo) +{ + m_failed = false; + ensureOriginalLoaded(); + + if (m_failed) { + return; + } + + const auto scaledSize = geo.scaledImageSize(m_original.size()); + + m_thumbnail = m_original.scaled( + scaledSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); +} + + +Files::Files() + : m_selection(BadIndex), m_filtered(false) +{ +} + +void Files::clear() +{ + m_allFiles.clear(); + m_filteredFiles.clear(); + m_selection = BadIndex; + m_filtered = false; +} + +void Files::add(File f) +{ + m_allFiles.emplace_back(std::move(f)); +} + +void Files::addFiltered(File* f) +{ + m_filteredFiles.push_back(f); +} + +bool Files::empty() const +{ + if (m_filtered) { + return m_filteredFiles.empty(); + } else { + return m_allFiles.empty(); + } +} + +std::size_t Files::size() const +{ + if (m_filtered) { + return m_filteredFiles.size(); + } else { + return m_allFiles.size(); + } +} + +void Files::switchToAll() +{ + m_filtered = false; + m_filteredFiles.clear(); +} + +void Files::switchToFiltered() +{ + m_filtered = true; + m_filteredFiles.clear(); +} + +const File* Files::get(std::size_t i) const +{ + if (m_filtered) { + if (i < m_filteredFiles.size()) { + return m_filteredFiles[i]; + } + } else { + if (i < m_allFiles.size()) { + return &m_allFiles[i]; + } + } + + return nullptr; +} + +File* Files::get(std::size_t i) +{ + return const_cast<File*>(std::as_const(*this).get(i)); +} + +std::size_t Files::indexOf(const File* f) const +{ + if (m_filtered) { + for (std::size_t i=0; i<m_filteredFiles.size(); ++i) { + if (m_filteredFiles[i] == f) { + return i; + } + } + } else { + for (std::size_t i=0; i<m_allFiles.size(); ++i) { + if (&m_allFiles[i] == f) { + return i; + } + } + } + + return BadIndex; +} + +const File* Files::selectedFile() const +{ + return get(m_selection); +} + +File* Files::selectedFile() +{ + return get(m_selection); +} + +std::size_t Files::selectedIndex() const +{ + return m_selection; +} + +void Files::select(std::size_t i) +{ + m_selection = i; +} + +std::vector<File>& Files::allFiles() +{ + return m_allFiles; +} + +bool Files::isFiltered() const +{ + return m_filtered; +} + + +PaintContext::PaintContext(QWidget* w, Geometry geo) + : painter(w), geo(geo), file(nullptr), thumbIndex(0), fileIndex(0) +{ +} + +} // namespace diff --git a/src/modinfodialogimages.h b/src/modinfodialogimages.h new file mode 100644 index 00000000..8d9b965b --- /dev/null +++ b/src/modinfodialogimages.h @@ -0,0 +1,385 @@ +#ifndef MODINFODIALOGIMAGES_H +#define MODINFODIALOGIMAGES_H + +#include "modinfodialogtab.h" +#include "filterwidget.h" +#include <QScrollBar> + +class ImagesTab; + +namespace ImagesTabHelpers +{ + +static constexpr std::size_t BadIndex = std::numeric_limits<std::size_t>::max(); + +// vertical scrollbar, this is only to handle wheel events to scroll by one +// instead of the system's scroll setting +// +class Scrollbar : public QScrollBar +{ +public: + using QScrollBar::QScrollBar; + void setTab(ImagesTab* tab); + +protected: + // forwards to ImagesTab::thumbnailAreaWheelEvent() + // + void wheelEvent(QWheelEvent* event) override; + +private: + ImagesTab* m_tab = nullptr; +}; + + +// widget inside the scroller, calls ImagesTab::paintThumbnailArea() when +// needed and also forwards mouse clicks and tooltip events +// +class ThumbnailsWidget : public QWidget +{ + Q_OBJECT; + +public: + using QWidget::QWidget; + void setTab(ImagesTab* tab); + +protected: + // forwards to ImagesTab::paintThumbnailArea() + // + void paintEvent(QPaintEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaMouseEvent() + // + void mousePressEvent(QMouseEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaWheelEvent() + // + void wheelEvent(QWheelEvent* e); + + // forwards to ImagesTab::scrollAreaResized() + // + void resizeEvent(QResizeEvent* e) override; + + // forwards to ImagesTab::thumbnailAreaKeyPressEvent() + // + void keyPressEvent(QKeyEvent* e) override; + + // forwards to ImagesTab::showTooltip for tooltip events + // + bool event(QEvent* e) override; + +private: + ImagesTab* m_tab = nullptr; +}; + + +// a widget that draws an image scaled to fit while keeping the aspect ratio +// +class ScalableImage : public QWidget +{ + Q_OBJECT; + +public: + ScalableImage(QString path={}); + + // sets the image to draw + void setImage(const QString& path); + void setImage(QImage image); + + // removes the image, won't draw the border nor the image + void clear(); + + // tells the QWidget's layout manager this widget is always square + bool hasHeightForWidth() const override; + int heightForWidth(int w) const override; + + // sets the colors + void setColors(const QColor& border, const QColor& background); + +protected: + void paintEvent(QPaintEvent* e) override; + +private: + QString m_path; + QImage m_original, m_scaled; + int m_border; + QColor m_borderColor, m_backgroundColor; +}; + + +struct Theme +{ + QColor borderColor, backgroundColor, textColor; + QColor highlightBackgroundColor, highlightTextColor; + QFont font; +}; + + +struct Metrics +{ + // space outside the thumbnail border + int margins; + + // size of the border + int border; + + // space between the border and the image + int padding; + + // spacing between the thumbnail and the text + int textSpacing; + + // height of the text + int textHeight; + + // spacing between thumbnails + int spacing; + + Metrics(); +}; + + +// handles all the geometry calculations by ImagesTab for painting or handling +// mouse clicks +// +// a thumbnail looks like this: +// +// +-----------------------+ <--- thumb rect +// | margins | +// | | +// | +-border--------+ <------- border rect +// | | padding | | +// | | | | +// | | +-------+ <----------- image rect +// | | | | | | +// | | | image | | | +// | | | | | | +// | | +-------+ | | +// | | | | +// | +---------------+ | +// | text spacing | +// | +---------------+ <------- text rect +// | | text | | +// | +---------------+ | +// | | +// +-----------------------+ +// +// spacing +// +// +-----------------------+ <-- thumb rect +// | margins | +// | | +// .... +// +// +class Geometry +{ +public: + Geometry(QSize widgetSize, Metrics metrics); + + // returns the number of images fully visible in the widget + // + std::size_t fullyVisibleCount() const; + + // rectangle around the whole thumbnail + // + QRect thumbRect(std::size_t i) const; + + // rectangle of the border for the given thumbnail + // + QRect borderRect(std::size_t i) const; + + // rectangle of the image for the given thumbnail + // + QRect imageRect(std::size_t i) const; + + // rectangle of the text for the given thumbnail + // + QRect textRect(std::size_t i) const; + + // rectangle that responds to selection: includes the border and extends down + // to the text + // + QRect selectionRect(std::size_t i) const; + + // returns the index of the image at the given point; this does not take into + // account any scrolling, the image at the top of widget is always 0 + // + // returns BadIndex if there's no thumbnail at this point + // + std::size_t indexAt(const QPoint& p) const; + + // returns the size of the image that fits in imageRect() while keeping the + // same aspect ratio as the given one + // + QSize scaledImageSize(const QSize& originalSize) const; + +private: + // size of the widget containing all the thumbnails + const QSize m_widgetSize; + + // metrics + const Metrics m_metrics; + + // rectangle of the first thumbnail on top + const QRect m_topRect; + + + // calculates the top rectangle + // + QRect calcTopRect() const; +}; + + +class File +{ +public: + File(QString path); + + void ensureOriginalLoaded(); + + const QString& path() const; + const QString& filename() const; + const QImage& original() const; + const QImage& thumbnail() const; + bool failed() const; + + void loadIfNeeded(const Geometry& geo); + +private: + QString m_path; + mutable QString m_filename; + QImage m_original, m_thumbnail; + bool m_failed; + + bool needsLoad(const Geometry& geo) const; + void load(const Geometry& geo); +}; + + +class Files +{ +public: + Files(); + + void clear(); + + void add(File f); + void addFiltered(File* f); + + bool empty() const; + std::size_t size() const; + + void switchToAll(); + void switchToFiltered(); + + const File* get(std::size_t i) const; + File* get(std::size_t i); + std::size_t indexOf(const File* f) const; + + const File* selectedFile() const; + File* selectedFile(); + std::size_t selectedIndex() const; + void select(std::size_t i); + + std::vector<File>& allFiles(); + + bool isFiltered() const; + +private: + std::vector<File> m_allFiles; + std::vector<File*> m_filteredFiles; + std::size_t m_selection; + bool m_filtered; +}; + + +struct PaintContext +{ + mutable QPainter painter; + Geometry geo; + File* file; + std::size_t thumbIndex; + std::size_t fileIndex; + + PaintContext(QWidget* w, Geometry geo); +}; + +} // namespace + + +class ImagesTab : public ModInfoDialogTab +{ + Q_OBJECT; + friend class ImagesTabHelpers::Scrollbar; + friend class ImagesTabHelpers::ThumbnailsWidget; + +public: + ImagesTab(ModInfoDialogTabContext cx); + + void clear() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +private: + enum class Visibility + { + Ignore = 0, + Full, + Partial + }; + + using ScalableImage = ImagesTabHelpers::ScalableImage; + using Files = ImagesTabHelpers::Files; + using File = ImagesTabHelpers::File; + using Theme = ImagesTabHelpers::Theme; + using Metrics = ImagesTabHelpers::Metrics; + using PaintContext = ImagesTabHelpers::PaintContext; + using Geometry = ImagesTabHelpers::Geometry; + + ScalableImage* m_image; + std::vector<QString> m_supportedFormats; + Files m_files; + FilterWidget m_filter; + bool m_ddsAvailable, m_ddsEnabled; + Theme m_theme; + Metrics m_metrics; + + void getSupportedFormats(); + void enableDDS(bool b); + + void scrollAreaResized(const QSize& s); + void paintThumbnailsArea(QPaintEvent* e); + void thumbnailAreaMouseEvent(QMouseEvent* e); + void thumbnailAreaWheelEvent(QWheelEvent* e); + bool thumbnailAreaKeyPressEvent(QKeyEvent* e); + void onScrolled(); + + void showTooltip(QHelpEvent* e); + void onExplore(); + void onShowDDS(); + void onFilterChanged(); + + void select(std::size_t i, Visibility v=Visibility::Full); + void moveSelection(int by); + void ensureVisible(std::size_t i, Visibility v); + + std::size_t fileIndexAtPos(const QPoint& p) const; + const File* fileAtPos(const QPoint& p) const; + + Geometry makeGeometry() const; + + void paintThumbnail(const PaintContext& cx); + void paintThumbnailBackground(const PaintContext& cx); + void paintThumbnailBorder(const PaintContext& cx); + void paintThumbnailImage(const PaintContext& cx); + void paintThumbnailText(const PaintContext& cx); + + void checkFiltering(); + void switchToAll(); + void switchToFiltered(); + void updateScrollbar(); +}; + +#endif // MODINFODIALOGIMAGES_H diff --git a/src/modinfodialognexus.cpp b/src/modinfodialognexus.cpp new file mode 100644 index 00000000..04683c89 --- /dev/null +++ b/src/modinfodialognexus.cpp @@ -0,0 +1,360 @@ +#include "modinfodialognexus.h" +#include "ui_modinfodialog.h" +#include "settings.h" +#include "organizercore.h" +#include "iplugingame.h" +#include "bbcode.h" +#include <versioninfo.h> +#include <utility.h> + +namespace shell = MOBase::shell; + +bool isValidModID(int id) +{ + return (id > 0); +} + +NexusTab::NexusTab(ModInfoDialogTabContext cx) : + ModInfoDialogTab(std::move(cx)), m_requestStarted(false), m_loading(false) +{ + ui->modID->setValidator(new QIntValidator(ui->modID)); + ui->endorse->setVisible(core().settings().endorsementIntegration()); + + connect(ui->modID, &QLineEdit::editingFinished, [&]{ onModIDChanged(); }); + connect( + ui->sourceGame, + static_cast<void (QComboBox::*)(int)>(&QComboBox::currentIndexChanged), + [&]{ onSourceGameChanged(); }); + connect(ui->version, &QLineEdit::editingFinished, [&]{ onVersionChanged(); }); + + connect(ui->refresh, &QPushButton::clicked, [&]{ onRefreshBrowser(); }); + connect(ui->visitNexus, &QPushButton::clicked, [&]{ onVisitNexus(); }); + connect(ui->endorse, &QPushButton::clicked, [&]{ onEndorse(); }); + connect(ui->track, &QPushButton::clicked, [&]{ onTrack(); }); + + connect(ui->hasCustomURL, &QCheckBox::toggled, [&]{ onCustomURLToggled(); }); + connect(ui->customURL, &QLineEdit::editingFinished, [&]{ onCustomURLChanged(); }); + connect(ui->visitCustomURL, &QPushButton::clicked, [&]{ onVisitCustomURL(); }); +} + +NexusTab::~NexusTab() +{ + cleanup(); +} + +void NexusTab::cleanup() +{ + if (m_modConnection) { + disconnect(m_modConnection); + m_modConnection = {}; + } +} + +void NexusTab::clear() +{ + ui->modID->clear(); + ui->sourceGame->clear(); + ui->version->clear(); + ui->browser->setPage(new NexusTabWebpage(ui->browser)); + ui->hasCustomURL->setChecked(false); + ui->customURL->clear(); + setHasData(false); +} + +void NexusTab::update() +{ + QScopedValueRollback loading(m_loading, true); + + clear(); + + ui->modID->setText(QString("%1").arg(mod().getNexusID())); + + QString gameName = mod().getGameName(); + ui->sourceGame->addItem( + core().managedGame()->gameName(), + core().managedGame()->gameShortName()); + + if (core().managedGame()->validShortNames().size() == 0) { + ui->sourceGame->setDisabled(true); + } else { + for (auto game : plugin().plugins<MOBase::IPluginGame>()) { + for (QString gameName : core().managedGame()->validShortNames()) { + if (game->gameShortName().compare(gameName, Qt::CaseInsensitive) == 0) { + ui->sourceGame->addItem(game->gameName(), game->gameShortName()); + break; + } + } + } + } + + ui->sourceGame->setCurrentIndex(ui->sourceGame->findData(gameName)); + + auto* page = new NexusTabWebpage(ui->browser); + ui->browser->setPage(page); + + connect( + page, &NexusTabWebpage::linkClicked, + [&](const QUrl& url){ shell::OpenLink(url); }); + + ui->endorse->setEnabled( + (mod().endorsedState() == ModInfo::ENDORSED_FALSE) || + (mod().endorsedState() == ModInfo::ENDORSED_NEVER)); + + setHasData(mod().getNexusID() >= 0); +} + +void NexusTab::firstActivation() +{ + updateWebpage(); +} + +void NexusTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) +{ + cleanup(); + + ModInfoDialogTab::setMod(mod, origin); + + m_modConnection = connect( + mod.data(), &ModInfo::modDetailsUpdated, [&]{ onModChanged(); }); +} + +bool NexusTab::usesOriginFiles() const +{ + return false; +} + +void NexusTab::updateVersionColor() +{ + if (mod().getVersion() != mod().getNewestVersion()) { + ui->version->setStyleSheet("color: red"); + ui->version->setToolTip(tr("Current Version: %1").arg( + mod().getNewestVersion().canonicalString())); + } else { + ui->version->setStyleSheet("color: green"); + ui->version->setToolTip(tr("No update available")); + } +} + +void NexusTab::updateWebpage() +{ + const int modID = mod().getNexusID(); + + if (isValidModID(modID)) { + const QString nexusLink = NexusInterface::instance(&plugin()) + ->getModURL(modID, mod().getGameName()); + + ui->visitNexus->setToolTip(nexusLink); + refreshData(modID); + } else { + onModChanged(); + } + + ui->version->setText(mod().getVersion().displayString()); + ui->hasCustomURL->setChecked(mod().hasCustomURL()); + ui->customURL->setText(mod().getCustomURL()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); + + updateTracking(); +} + +void NexusTab::updateTracking() +{ + if (mod().trackedState() == ModInfo::TRACKED_TRUE) { + ui->track->setChecked(true); + ui->track->setText(tr("Tracked")); + } else { + ui->track->setChecked(false); + ui->track->setText(tr("Untracked")); + } +} + +void NexusTab::refreshData(int modID) +{ + if (tryRefreshData(modID)) { + m_requestStarted = true; + } else { + onModChanged(); + } +} + +bool NexusTab::tryRefreshData(int modID) +{ + if (isValidModID(modID) && !m_requestStarted) { + if (mod().updateNXMInfo()) { + ui->browser->setHtml(""); + return true; + } + } + + return false; +} + +void NexusTab::onModChanged() +{ + m_requestStarted = false; + + const QString nexusDescription = mod().getNexusDescription(); + + QString descriptionAsHTML = R"( +<html> + <head> + <style class="nexus-description"> + body + { + font-family: sans-serif; + font-size: 14px; + background: #404040; + color: #f1f1f1; + max-width: 1060px; + margin-left: auto; + margin-right: auto; + } + + a + { + color: #8197ec; + text-decoration: none; + } + </style> + </head> + <body>%1</body> +</html>)"; + + if (nexusDescription.isEmpty()) { + descriptionAsHTML = descriptionAsHTML.arg(tr(R"( + <div style="text-align: center;"> + <p>This mod does not have a valid Nexus ID. You can add a custom web + page for it in the "Custom URL" box below.</p> + </div>)")); + } else { + descriptionAsHTML = descriptionAsHTML.arg( + BBCode::convertToHTML(nexusDescription)); + } + + ui->browser->page()->setHtml(descriptionAsHTML); + updateVersionColor(); + updateTracking(); +} + +void NexusTab::onModIDChanged() +{ + if (m_loading) { + return; + } + + const int oldID = mod().getNexusID(); + const int newID = ui->modID->text().toInt(); + + if (oldID != newID){ + mod().setNexusID(newID); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + + ui->browser->page()->setHtml(""); + + if (isValidModID(newID)) { + refreshData(newID); + } + } +} + +void NexusTab::onSourceGameChanged() +{ + if (m_loading) { + return; + } + + for (auto game : plugin().plugins<MOBase::IPluginGame>()) { + if (game->gameName() == ui->sourceGame->currentText()) { + mod().setGameName(game->gameShortName()); + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + refreshData(mod().getNexusID()); + return; + } + } +} + +void NexusTab::onVersionChanged() +{ + if (m_loading) { + return; + } + + MOBase::VersionInfo version(ui->version->text()); + mod().setVersion(version); + updateVersionColor(); +} + +void NexusTab::onRefreshBrowser() +{ + const auto modID = mod().getNexusID(); + + if (isValidModID(modID)) { + mod().setLastNexusQuery(QDateTime::fromSecsSinceEpoch(0)); + updateWebpage(); + } else { + qInfo("Mod has no valid Nexus ID, info can't be updated."); + } +} + +void NexusTab::onVisitNexus() +{ + const int modID = mod().getNexusID(); + + if (isValidModID(modID)) { + const QString nexusLink = NexusInterface::instance(&plugin()) + ->getModURL(modID, mod().getGameName()); + + shell::OpenLink(QUrl(nexusLink)); + } +} + +void NexusTab::onEndorse() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m=modPtr()]{ m->endorse(true); }); +} + +void NexusTab::onTrack() +{ + // use modPtr() instead of mod() or this because the callback may be + // executed after the dialog is closed + core().loggedInAction(parentWidget(), [m=modPtr()] { + if (m->trackedState() == ModInfo::TRACKED_TRUE) { + m->track(false); + } else { + m->track(true); + } + }); +} + +void NexusTab::onCustomURLToggled() +{ + if (m_loading) { + return; + } + + mod().setHasCustomURL(ui->hasCustomURL->isChecked()); + ui->customURL->setEnabled(mod().hasCustomURL()); + ui->visitCustomURL->setEnabled(mod().hasCustomURL()); +} + +void NexusTab::onCustomURLChanged() +{ + if (m_loading) { + return; + } + + mod().setCustomURL(ui->customURL->text()); + ui->visitCustomURL->setToolTip(mod().parseCustomURL().toString()); +} + +void NexusTab::onVisitCustomURL() +{ + const auto url = mod().parseCustomURL(); + if (url.isValid()) { + shell::OpenLink(url); + } +} diff --git a/src/modinfodialognexus.h b/src/modinfodialognexus.h new file mode 100644 index 00000000..7f894dbf --- /dev/null +++ b/src/modinfodialognexus.h @@ -0,0 +1,74 @@ +#ifndef MODINFODIALOGNEXUS_H +#define MODINFODIALOGNEXUS_H + +#include "modinfodialogtab.h" + +class NexusTabWebpage : public QWebEnginePage +{ + Q_OBJECT + +public: + NexusTabWebpage(QObject* parent = 0) + : QWebEnginePage(parent) + { + } + + bool acceptNavigationRequest( + const QUrl & url, QWebEnginePage::NavigationType type, bool) override + { + if (type == QWebEnginePage::NavigationTypeLinkClicked) + { + emit linkClicked(url); + return false; + } + + return true; + } + +signals: + void linkClicked(const QUrl&); +}; + + +class NexusTab : public ModInfoDialogTab +{ +public: + NexusTab(ModInfoDialogTabContext cx); + + ~NexusTab(); + + void clear() override; + void update() override; + void firstActivation() override; + void setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) override; + bool usesOriginFiles() const override; + +private: + QMetaObject::Connection m_modConnection; + bool m_requestStarted; + bool m_loading; + + void cleanup(); + void updateVersionColor(); + void updateWebpage(); + void updateTracking(); + + void refreshData(int modID); + bool tryRefreshData(int modID); + void onModChanged(); + + void onModIDChanged(); + void onSourceGameChanged(); + void onVersionChanged(); + + void onRefreshBrowser(); + void onVisitNexus(); + void onEndorse(); + void onTrack(); + + void onCustomURLToggled(); + void onCustomURLChanged(); + void onVisitCustomURL(); +}; + +#endif // MODINFODIALOGNEXUS_H diff --git a/src/modinfodialogtab.cpp b/src/modinfodialogtab.cpp new file mode 100644 index 00000000..9748d059 --- /dev/null +++ b/src/modinfodialogtab.cpp @@ -0,0 +1,209 @@ +#include "modinfodialogtab.h" +#include "ui_modinfodialog.h" +#include "texteditor.h" +#include "directoryentry.h" +#include "modinfo.h" + +ModInfoDialogTab::ModInfoDialogTab(ModInfoDialogTabContext cx) : + ui(cx.ui), m_core(cx.core), m_plugin(cx.plugin), m_parent(cx.parent), + m_origin(cx.origin), m_tabID(cx.id), m_hasData(false), m_firstActivation(true) +{ +} + +void ModInfoDialogTab::activated() +{ + if (m_firstActivation) { + m_firstActivation = false; + firstActivation(); + } +} + +void ModInfoDialogTab::resetFirstActivation() +{ + m_firstActivation = true; +} + +void ModInfoDialogTab::update() +{ + // no-op +} + +bool ModInfoDialogTab::feedFile(const QString&, const QString&) +{ + // no-op + return false; +} + +void ModInfoDialogTab::firstActivation() +{ + // no-op +} + +bool ModInfoDialogTab::canClose() +{ + return true; +} + +void ModInfoDialogTab::saveState(Settings&) +{ + // no-op +} + +void ModInfoDialogTab::restoreState(const Settings& s) +{ + // no-op +} + +bool ModInfoDialogTab::deleteRequested() +{ + // no-op + return false; +} + +bool ModInfoDialogTab::canHandleSeparators() const +{ + return false; +} + +bool ModInfoDialogTab::canHandleUnmanaged() const +{ + return false; +} + +bool ModInfoDialogTab::usesOriginFiles() const +{ + return true; +} + +void ModInfoDialogTab::setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin) +{ + m_mod = mod; + m_origin = origin; +} + +ModInfo& ModInfoDialogTab::mod() const +{ + Q_ASSERT(m_mod); + return *m_mod; +} + +ModInfoPtr ModInfoDialogTab::modPtr() const +{ + Q_ASSERT(m_mod); + return m_mod; +} + +MOShared::FilesOrigin* ModInfoDialogTab::origin() const +{ + return m_origin; +} + +ModInfoTabIDs ModInfoDialogTab::tabID() const +{ + return m_tabID; +} + +bool ModInfoDialogTab::hasData() const +{ + return m_hasData; +} + +OrganizerCore& ModInfoDialogTab::core() +{ + return m_core; +} + +PluginContainer& ModInfoDialogTab::plugin() +{ + return m_plugin; +} + +QWidget* ModInfoDialogTab::parentWidget() +{ + return m_parent; +} + +void ModInfoDialogTab::emitOriginModified() +{ + if (m_origin) { + emit originModified(m_origin->getID()); + } +} + +void ModInfoDialogTab::emitModOpen(QString name) +{ + emit modOpen(name); +} + +void ModInfoDialogTab::setHasData(bool b) +{ + if (m_hasData != b) { + m_hasData = b; + emit hasDataChanged(); + } +} + +void ModInfoDialogTab::setFocus() +{ + emit wantsFocus(); +} + + +NotesTab::NotesTab(ModInfoDialogTabContext cx) + : ModInfoDialogTab(std::move(cx)) +{ + connect(ui->comments, &QLineEdit::editingFinished, [&]{ onComments(); }); + connect(ui->notes, &HTMLEditor::editingFinished, [&]{ onNotes(); }); +} + +void NotesTab::clear() +{ + ui->comments->clear(); + ui->notes->clear(); + setHasData(false); +} + +void NotesTab::update() +{ + const auto comments = mod().comments(); + const auto notes = mod().notes(); + + ui->comments->setText(comments); + ui->notes->setText(notes); + checkHasData(); +} + +bool NotesTab::canHandleSeparators() const +{ + return true; +} + +void NotesTab::onComments() +{ + mod().setComments(ui->comments->text()); + checkHasData(); +} + +void NotesTab::onNotes() +{ + // Avoid saving html stub if notes field is empty. + if (ui->notes->toPlainText().isEmpty()) { + mod().setNotes({}); + } else { + mod().setNotes(ui->notes->toHtml()); + } + + checkHasData(); +} + +bool NotesTab::usesOriginFiles() const +{ + return false; +} + +void NotesTab::checkHasData() +{ + setHasData( + !ui->comments->text().isEmpty() || + !ui->notes->toPlainText().isEmpty()); +} diff --git a/src/modinfodialogtab.h b/src/modinfodialogtab.h new file mode 100644 index 00000000..c43fa076 --- /dev/null +++ b/src/modinfodialogtab.h @@ -0,0 +1,341 @@ +#ifndef MODINFODIALOGTAB_H +#define MODINFODIALOGTAB_H + +#include "modinfodialogfwd.h" +#include <QObject> + +namespace MOShared { class FilesOrigin; } +namespace Ui { class ModInfoDialog; } + +class Settings; +class OrganizerCore; + +// helper struct to avoid passing too much stuff to tab constructors +// +struct ModInfoDialogTabContext +{ + OrganizerCore& core; + PluginContainer& plugin; + QWidget* parent; + Ui::ModInfoDialog* ui; + ModInfoTabIDs id; + ModInfoPtr mod; + MOShared::FilesOrigin* origin; + + ModInfoDialogTabContext( + OrganizerCore& core, + PluginContainer& plugin, + QWidget* parent, + Ui::ModInfoDialog* ui, + ModInfoTabIDs id, + ModInfoPtr mod, + MOShared::FilesOrigin* origin) : + core(core), plugin(plugin), parent(parent), ui(ui), id(id), + mod(mod), origin(origin) + { + } +}; + + +// base class for all tabs in the mod info dialog +// +// when the dialog is opened or when next/previous is clicked, the sequence is: +// setMod(), clear(), feedFile() an update() +// +// when the dialog is closed, canClose() is called on all tabs +// +// when a tab is selected for the first time for the current mod, +// firstActivation() is called; this is used by NexusTab to refresh stuff +// +// when the dialog is first shown, restoreState() is called on all tabs and +// saveState() is called when the dialog is closed +// +// there isn't a good framework for keyboard shortcuts because only the delete +// key is used for now, which calls deletedRequested() on all tabs until one +// returns true +// +// each tab override canHandleSeparators() and canHandleUnmanaged() to return +// true if they can handle separators or unmanaged mods; if these return false +// (which they do by default), the tabs will be removed from the widget entirely +// +// when tabs modify the origin and call emitOriginModified() (such as the +// conflicts tabs), all tabs that return true in usesOriginFiles() will go +// through the full update sequence as above +// +// tabs can call emitModOpen() to request showing a different mod +// +// hasDataChanged() should be called when a tab goes from having data to being +// empty or vice versa; this will update the tab text colour +// +class ModInfoDialogTab : public QObject +{ + Q_OBJECT; + +public: + ModInfoDialogTab(const ModInfoDialogTab&) = delete; + ModInfoDialogTab& operator=(const ModInfoDialogTab&) = delete; + ModInfoDialogTab(ModInfoDialogTab&&) = default; + ModInfoDialogTab& operator=(ModInfoDialogTab&&) = default; + virtual ~ModInfoDialogTab() = default; + + // called by ModInfoDialog every time this tab is selected; this will call + // firstActivation() the first time it's called, until resetFirstActivation() + // is called + // + void activated(); + + // called by ModInfoDialog when the selected mod has changed, to make sure + // activated() will call firstActivation() next time + // + void resetFirstActivation(); + + + // called when the selected mod changed, `mod` can never be empty, but + // `origin` can (if the mod is not active, for example) + // + // derived classes can override this to connect to events on the mod for + // examples (see NexusTab), but must call the base class implementation + // + virtual void setMod(ModInfoPtr mod, MOShared::FilesOrigin* origin); + + // this tab should clear its user interface; clear() will always be called + // before feedFile() and update() + // + virtual void clear() = 0; + + // the dialog will go through each file in the mod and call feedFile() + // with it on all tabs; if a tab handles the file, it should return true to + // prevent other tabs from displaying it + // + // this prevents individual tabs from having to go through the filesystem + // independently, which would kill performance, but it cannot be the only way + // to update tabs because some of them don't actually use files (like + // NotesTab) or they use the internal structures such as `FilesOrigin` (like + // ConflictsTab) + // + // once all the files have been fed to tabs, update() will be called; tabs + // that need to do additional work after being fed files, or tabs that are + // unable to use feedFile() at all can use update() to do work + // + virtual bool feedFile(const QString& rootPath, const QString& filename); + + // called after all the files on the filesystem have been sent through + // feedFile() + // + // this can be used to do a final processing of the files handled by + // feedFile() (ImagesTab will set up the scrollbars, for example) or something + // completely different (CategoriesTab will fill up the lists) + // + virtual void update(); + + // called when a tab is visible for the first time for the current mod, can + // be used to do expensive work that's not worth doing until the tab is + // actually selected by the user + // + virtual void firstActivation(); + + // called when closing the dialog, can return false to stop the dialog from + // closing + // + // this is typically used by tabs that require manual saving, like text files; + // tabs that refuse to close should focus themselves before showing whatever + // confirmation they have + // + virtual bool canClose(); + + + // called after the dialog is closed, tabs should save whatever UI state they + // want + // + virtual void saveState(Settings& s); + + // called before the is shown, tabs should restore whatever UI state they + // saved in saveState() + // + virtual void restoreState(const Settings& s); + + + // called on the selected tab when the Delete key is pressed on the keyboard; + // tabs _must_ check which widget currently has focus to decide whether this + // should be handled or not; do not blindly delete stuff when this is called + // + // if the delete request was handled, this should return true + // + virtual bool deleteRequested(); + + + // return true if this tab can handle a separator mod, defaults to false; + // when this returns false, the tab is removed from the widget entirely + // + // if a tab can show meaningful information about a separator (like + // categories or notes), it should return true + // + virtual bool canHandleSeparators() const; + + // return true if this tab can handle unmanaged mods, defaults to false; + // when this returns false, the tab is removed from the widget entirely + // + virtual bool canHandleUnmanaged() const; + + // return true if this tab uses the files from the mod's origin, defaults to + // false + // + // tabs that do not care about the files inside a mod should return false, + // such as the notes or categories tab + // + // mods that return true will be updated anytime a tab calls + // emitOriginModifed() + // + virtual bool usesOriginFiles() const; + + + // returns the currently selected mod + // + ModInfo& mod() const; + + // returns the currently selected mod, can never be empty + // + ModInfoPtr modPtr() const; + + // returns the origin of the selected mod; this can be null for mods that + // don't have an origin, like deactivated mods + // + MOShared::FilesOrigin* origin() const; + + + // return this tab's ID + // + ModInfoTabIDs tabID() const; + + // returns whether this tab has data; derived classes should call setHasData() + // + bool hasData() const; + +signals: + // emitted when a tab modified the files in a mod + // + void originModified(int originID); + + // emitted when a tab wants to open a mod by name + // + void modOpen(QString name); + + // emitted when a tab used to have data and is now empty, or vice versa + // + void hasDataChanged(); + + // emitted when a tab wants focus + // + void wantsFocus(); + +protected: + Ui::ModInfoDialog* ui; + + ModInfoDialogTab(ModInfoDialogTabContext cx); + + OrganizerCore& core(); + PluginContainer& plugin(); + QWidget* parentWidget(); + + // emits originModified + // + void emitOriginModified(); + + // emits modOpen + // + void emitModOpen(QString name); + + // emits hasDataChanged + // + void setHasData(bool b); + + // emits wantsFocus + // + void setFocus(); + + + // saves the sate of the given widget in geometry/modinfodialog_[objectname] + // + // this needs to be a template because saveState() and restoreState() are + // not in QWidget, but they're in various widgets + // + template <class Widget> + void saveWidgetState(QSettings& s, Widget* w) + { + s.setValue(settingName(w), w->saveState()); + } + + // restores the sate of the given widget from + // geometry/modinfodialog_[objectname] + // + // this needs to be a template because saveState() and restoreState() are + // not in QWidget, but they're in various widgets + // + template <class Widget> + void restoreWidgetState(const QSettings& s, Widget* w) + { + if (s.contains(settingName(w))) { + w->restoreState(s.value(settingName(w)).toByteArray()); + } + } + +private: + // core + OrganizerCore& m_core; + + // plugin + PluginContainer& m_plugin; + + // parent widget, used to display modal dialogs + QWidget* m_parent; + + // current mod, never null + ModInfoPtr m_mod; + + // current mod origin, may be null + MOShared::FilesOrigin* m_origin; + + // tab ID + ModInfoTabIDs m_tabID; + + // whether the tab has data + bool m_hasData; + + // true if the tab has never been selected for the current mod + bool m_firstActivation; + + + // used by saveWidgetState() and restoreWidgetState() + // + QString settingName(QWidget* w) + { + return "geometry/modinfodialog_" + w->objectName(); + } +}; + + +// the Notes tab +// +class NotesTab : public ModInfoDialogTab +{ +public: + NotesTab(ModInfoDialogTabContext cx); + + void clear() override; + void update() override; + + // returns true, separators can have notes + // + bool canHandleSeparators() const override; + + // returns false, notes don't use files + // + bool usesOriginFiles() const override; + +private: + void onComments(); + void onNotes(); + void checkHasData(); +}; + +#endif // MODINFODIALOGTAB_H diff --git a/src/modinfodialogtextfiles.cpp b/src/modinfodialogtextfiles.cpp new file mode 100644 index 00000000..7a09fa4e --- /dev/null +++ b/src/modinfodialogtextfiles.cpp @@ -0,0 +1,252 @@ +#include "modinfodialogtextfiles.h" +#include "ui_modinfodialog.h" +#include "modinfodialog.h" +#include "settings.h" +#include <QMessageBox> + +class FileListModel : public QAbstractItemModel +{ +public: + void clear() + { + m_files.clear(); + endResetModel(); + } + + QModelIndex index(int row, int col, const QModelIndex& ={}) const override + { + return createIndex(row, col); + } + + QModelIndex parent(const QModelIndex&) const override + { + return {}; + } + + int rowCount(const QModelIndex& ={}) const override + { + return static_cast<int>(m_files.size()); + } + + int columnCount(const QModelIndex& ={}) const override + { + return 1; + } + + QVariant data(const QModelIndex& index, int role) const override + { + if (role == Qt::DisplayRole) { + const auto row = index.row(); + if (row < 0) { + return {}; + } + + const auto i = static_cast<std::size_t>(row); + if (i >= m_files.size()) { + return {}; + } + + return m_files[i].text; + } + + return {}; + } + + void add(const QString& rootPath, QString fullPath) + { + QString text = fullPath.mid(rootPath.length() + 1); + m_files.emplace_back(std::move(fullPath), std::move(text)); + } + + void finished() + { + std::sort(m_files.begin(), m_files.end(), [](const auto& a, const auto& b) { + return (naturalCompare(a.text, b.text) < 0); + }); + + endResetModel(); + } + + QString fullPath(const QModelIndex& index) const + { + const auto row = index.row(); + if (row < 0) { + return {}; + } + + const auto i = static_cast<std::size_t>(row); + if (i >= m_files.size()) { + return {}; + } + + return m_files[i].fullPath; + } + +private: + struct File + { + QString fullPath; + QString text; + + File(QString fp, QString t) + : fullPath(std::move(fp)), text(std::move(t)) + { + } + }; + + std::deque<File> m_files; +}; + + +GenericFilesTab::GenericFilesTab( + ModInfoDialogTabContext cx, + QListView* list, QSplitter* sp, + TextEditor* e, QLineEdit* filter) : + ModInfoDialogTab(std::move(cx)), + m_list(list), m_editor(e), m_splitter(sp), m_model(new FileListModel) +{ + m_list->setModel(m_model); + m_editor->setupToolbar(); + + m_splitter->setSizes({200, 1}); + m_splitter->setStretchFactor(0, 0); + m_splitter->setStretchFactor(1, 1); + + m_filter.setEdit(filter); + m_filter.setList(m_list); + + QObject::connect( + m_list->selectionModel(), &QItemSelectionModel::currentRowChanged, + [&](auto current, auto previous){ onSelection(current, previous); }); +} + +void GenericFilesTab::clear() +{ + m_model->clear(); + select({}); + setHasData(false); +} + +bool GenericFilesTab::canClose() +{ + if (!m_editor->dirty()) { + return true; + } + + setFocus(); + + const int res = QMessageBox::question( + parentWidget(), + QObject::tr("Save changes?"), + QObject::tr("Save changes to \"%1\"?").arg(m_editor->filename()), + QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); + + if (res == QMessageBox::Cancel) { + return false; + } + + if (res == QMessageBox::Yes) { + m_editor->save(); + } + + return true; +} + +bool GenericFilesTab::feedFile(const QString& rootPath, const QString& fullPath) +{ + static constexpr const char* extensions[] = { + ".txt" + }; + + for (const auto* e : extensions) { + if (wantsFile(rootPath, fullPath)) { + m_model->add(rootPath, fullPath); + return true; + } + } + + return false; +} + +void GenericFilesTab::update() +{ + m_model->finished(); + setHasData(m_model->rowCount() > 0); +} + +void GenericFilesTab::saveState(Settings& s) +{ + saveWidgetState(s.directInterface(), m_splitter); +} + +void GenericFilesTab::restoreState(const Settings& s) +{ + restoreWidgetState(s.directInterface(), m_splitter); +} + +void GenericFilesTab::onSelection( + const QModelIndex& current, const QModelIndex& previous) +{ + if (!canClose()) { + m_list->selectionModel()->select(previous, QItemSelectionModel::Current); + return; + } + + select(current); +} + +void GenericFilesTab::select(const QModelIndex& index) +{ + if (!index.isValid()) { + m_editor->clear(); + m_editor->setEnabled(false); + return; + } + + m_editor->setEnabled(true); + m_editor->load(m_model->fullPath(m_filter.map(index))); +} + + +TextFilesTab::TextFilesTab(ModInfoDialogTabContext cx) + : GenericFilesTab(cx, + cx.ui->textFileList, cx.ui->tabTextSplitter, + cx.ui->textFileEditor, cx.ui->textFileFilter) +{ +} + +bool TextFilesTab::wantsFile(const QString& rootPath, const QString& fullPath) const +{ + static const QString extensions[] = {".txt"}; + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + return true; + } + } + + return false; +} + +IniFilesTab::IniFilesTab(ModInfoDialogTabContext cx) + : GenericFilesTab(cx, + cx.ui->iniFileList, cx.ui->tabIniSplitter, + cx.ui->iniFileEditor, cx.ui->iniFileFilter) +{ +} + +bool IniFilesTab::wantsFile(const QString& rootPath, const QString& fullPath) const +{ + static const QString extensions[] = {".ini", ".cfg"}; + static const QString meta("meta.ini"); + + for (const auto& e : extensions) { + if (fullPath.endsWith(e, Qt::CaseInsensitive)) { + if (!fullPath.endsWith(meta)) { + return true; + } + } + } + + return false; +} diff --git a/src/modinfodialogtextfiles.h b/src/modinfodialogtextfiles.h new file mode 100644 index 00000000..725ac999 --- /dev/null +++ b/src/modinfodialogtextfiles.h @@ -0,0 +1,64 @@ +#ifndef MODINFODIALOGTEXTFILES_H +#define MODINFODIALOGTEXTFILES_H + +#include "modinfodialogtab.h" +#include "filterwidget.h" +#include <QSplitter> +#include <QListView> + +class FileListItem; +class FileListModel; +class TextEditor; + +class GenericFilesTab : public ModInfoDialogTab +{ + Q_OBJECT; + +public: + void clear() override; + bool canClose() override; + bool feedFile(const QString& rootPath, const QString& fullPath) override; + void update() override; + void saveState(Settings& s) override; + void restoreState(const Settings& s) override; + +protected: + QListView* m_list; + TextEditor* m_editor; + QSplitter* m_splitter; + FileListModel* m_model; + FilterWidget m_filter; + + GenericFilesTab( + ModInfoDialogTabContext cx, + QListView* list, QSplitter* splitter, + TextEditor* editor, QLineEdit* filter); + + virtual bool wantsFile(const QString& rootPath, const QString& fullPath) const = 0; + +private: + void onSelection(const QModelIndex& current, const QModelIndex& previous); + void select(const QModelIndex& index); +}; + + +class TextFilesTab : public GenericFilesTab +{ +public: + TextFilesTab(ModInfoDialogTabContext cx); + +protected: + bool wantsFile(const QString& rootPath, const QString& fullPath) const override; +}; + + +class IniFilesTab : public GenericFilesTab +{ +public: + IniFilesTab(ModInfoDialogTabContext cx); + +protected: + bool wantsFile(const QString& rootPath, const QString& fullPath) const override; +}; + +#endif // MODINFODIALOGTEXTFILES_H diff --git a/src/modinforegular.cpp b/src/modinforegular.cpp index a271c4e8..448447e1 100644 --- a/src/modinforegular.cpp +++ b/src/modinforegular.cpp @@ -100,7 +100,70 @@ void ModInfoRegular::readMeta() m_Repository = metaFile.value("repository", "Nexus").toString(); m_Converted = metaFile.value("converted", false).toBool(); m_Validated = metaFile.value("validated", false).toBool(); - m_URL = metaFile.value("url", "").toString(); + + // this handles changes to how the URL works after 2.2.0 + // + // in 2.2.0, "hasCustomUrl" does not exist and "url" is only used when the mod + // id is invalid, although it can be set at any time in the mod info dialog + // + // post 2.2.0, a custom url can be set on any mod, whether the mod id is + // valid or not, so an additional flag "hasCustomURL" is required, with a + // corresponding checkbox in the mod info dialog + // + // there are several cases to handle to make sure no data is lost and to + // determine whether the user has set a custom url before: + // + // 1) some mods have an incorrect url set along with a valid mod id; + // there is apparently a bug with the fomod installer that can set the + // url of a mod to a value used by a _previous_ installation + // + // 2) it is possible to set the url even if the mod id is valid, in which + // case it is saved, but never used in 2.2.0 + // + // 3) opening the mod info dialog on the nexus tab for a mod that has a + // valid id will force the url to be the same as what the plugin gives + // back + // + // the algorithm is as follows: + // always read the url from the meta file and store it so this piece of data + // is never lost; the problem then only becomes about whether to enable + // hasCustomURL + // + // if hasCustomURL is present in the meta file, just read that and be + // done with it + // + // if not, then the flag depends on the mod id and the url + // if the mod id is valid, the custom url is disabled; although the url + // could be _set_ by the user when a mod id was valid, it was never + // _used_, so the behaviour won't change + // + // if the mod id is invalid, the url should normally be empty, unless the + // user specified one, in which case hasCustomURL should be true + // (the only case where this fails is if a mod id was valid before and + // the user visited the nexus tab, in which case the url was set + // automatically, but then the id was manually changed to 0 + // + // in that case, the mod id is invalid and the url is not empty, but it + // was never set by the user; this case is impossible to distinguish + // from a user manually entering a url, and so is handled as such) + + // always read the url + m_CustomURL = metaFile.value("url").toString(); + + if (metaFile.contains("hasCustomURL")) { + m_HasCustomURL = metaFile.value("hasCustomURL").toBool(); + } else { + if (m_NexusID > 0) { + // the mod id is valid, disable the custom url + m_HasCustomURL = false; + } else { + if (!m_CustomURL.isEmpty()) { + // the mod id is invalid and the url is not empty, enable it + m_HasCustomURL = true; + } + } + } + m_LastNexusQuery = QDateTime::fromString(metaFile.value("lastNexusQuery", "").toString(), Qt::ISODate); m_LastNexusUpdate = QDateTime::fromString(metaFile.value("lastNexusUpdate", "").toString(), Qt::ISODate); m_NexusLastModified = QDateTime::fromString(metaFile.value("nexusLastModified", QDateTime::currentDateTimeUtc()).toString(), Qt::ISODate); @@ -118,7 +181,7 @@ void ModInfoRegular::readMeta() m_EndorsedState = metaFile.value("endorsed", false).toBool() ? ENDORSED_TRUE : ENDORSED_FALSE; } } - + QString categoriesString = metaFile.value("category", "").toString(); QStringList categories = categoriesString.split(',', QString::SkipEmptyParts); @@ -166,7 +229,8 @@ void ModInfoRegular::saveMeta() metaFile.setValue("comments", m_Comments); metaFile.setValue("notes", m_Notes); metaFile.setValue("nexusDescription", m_NexusDescription); - metaFile.setValue("url", m_URL); + metaFile.setValue("url", m_CustomURL); + metaFile.setValue("hasCustomURL", m_HasCustomURL); metaFile.setValue("nexusFileStatus", m_NexusFileStatus); metaFile.setValue("lastNexusQuery", m_LastNexusQuery.toString(Qt::ISODate)); metaFile.setValue("lastNexusUpdate", m_LastNexusUpdate.toString(Qt::ISODate)); @@ -194,10 +258,14 @@ void ModInfoRegular::saveMeta() if (metaFile.status() == QSettings::NoError) { m_MetaInfoChanged = false; } else { - reportError(tr("failed to write %1/meta.ini: error %2").arg(absolutePath()).arg(metaFile.status())); + qCritical() + << QString("failed to write %1/meta.ini: error %2") + .arg(absolutePath()).arg(metaFile.status()); } } else { - reportError(tr("failed to write %1/meta.ini: error %2").arg(absolutePath()).arg(metaFile.status())); + qCritical() + << QString("failed to write %1/meta.ini: error %2") + .arg(absolutePath()).arg(metaFile.status()); } } } @@ -291,15 +359,27 @@ void ModInfoRegular::nxmRequestFailed(QString, int, int, QVariant userData, QNet bool ModInfoRegular::updateNXMInfo() { - QDateTime time = QDateTime::currentDateTimeUtc(); - QDateTime target = m_LastNexusQuery.addDays(1); - if (m_NexusID > 0 && time >= target) { + if (needsDescriptionUpdate()) { m_NexusBridge.requestDescription(m_GameName, m_NexusID, QVariant()); return true; } + return false; } +bool ModInfoRegular::needsDescriptionUpdate() const +{ + if (m_NexusID > 0) { + QDateTime time = QDateTime::currentDateTimeUtc(); + QDateTime target = m_LastNexusQuery.addDays(1); + + if (time >= target) { + return true; + } + } + + return false; +} void ModInfoRegular::setCategory(int categoryID, bool active) { @@ -753,18 +833,27 @@ void ModInfoRegular::setNexusLastModified(QDateTime time) emit modDetailsUpdated(true); } -void ModInfoRegular::setURL(QString const &url) +void ModInfoRegular::setCustomURL(QString const &url) { - m_URL = url; + m_CustomURL = url; m_MetaInfoChanged = true; } -QString ModInfoRegular::getURL() const +QString ModInfoRegular::getCustomURL() const { - return m_URL; + return m_CustomURL; } +void ModInfoRegular::setHasCustomURL(bool b) +{ + m_HasCustomURL = b; + m_MetaInfoChanged = true; +} +bool ModInfoRegular::hasCustomURL() const +{ + return m_HasCustomURL; +} QStringList ModInfoRegular::archives(bool checkOnDisk) { diff --git a/src/modinforegular.h b/src/modinforegular.h index f70487a2..705e66a8 100644 --- a/src/modinforegular.h +++ b/src/modinforegular.h @@ -397,15 +397,10 @@ public: void readMeta(); - /** - * @brief set the URL for a mod - */ - virtual void setURL(QString const &); - - /** - * @returns the URL for a mod - */ - virtual QString getURL() const; + virtual void setHasCustomURL(bool b) override; + virtual bool hasCustomURL() const override; + virtual void setCustomURL(QString const &) override; + virtual QString getCustomURL() const override; private: @@ -432,7 +427,8 @@ private: QString m_Notes; QString m_NexusDescription; QString m_Repository; - QString m_URL; + QString m_CustomURL; + bool m_HasCustomURL; QString m_GameName; mutable QStringList m_Archives; @@ -464,6 +460,7 @@ private: mutable std::vector<ModInfo::EContent> m_CachedContent; mutable QTime m_LastContentCheck; + bool needsDescriptionUpdate() const; }; diff --git a/src/organizercore.cpp b/src/organizercore.cpp index 2cad9ce8..99ddda1d 100644 --- a/src/organizercore.cpp +++ b/src/organizercore.cpp @@ -966,8 +966,8 @@ MOBase::IModInterface *OrganizerCore::installMod(const QString &fileName, "want to configure them now?"), QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes)) { - m_UserInterface->displayModInformation(modInfo, modIndex, - ModInfoDialog::TAB_INIFILES); + m_UserInterface->displayModInformation( + modInfo, modIndex, ModInfoTabIDs::IniFiles); } m_ModInstalled(modName); m_DownloadManager.markInstalled(fileName); @@ -1033,8 +1033,8 @@ void OrganizerCore::installDownload(int index) "want to configure them now?"), QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes)) { - m_UserInterface->displayModInformation(modInfo, modIndex, - ModInfoDialog::TAB_INIFILES); + m_UserInterface->displayModInformation( + modInfo, modIndex, ModInfoTabIDs::IniFiles); } m_ModInstalled(modName); @@ -2204,6 +2204,21 @@ void OrganizerCore::updateModsInDirectoryStructure(QMap<unsigned int, ModInfo::P } } +void OrganizerCore::loggedInAction(QWidget* parent, std::function<void ()> f) +{ + if (NexusInterface::instance(m_PluginContainer)->getAccessManager()->validated()) { + f(); + } else { + QString apiKey; + if (settings().getNexusApiKey(apiKey)) { + doAfterLogin([f]{ f(); }); + NexusInterface::instance(m_PluginContainer)->getAccessManager()->apiCheck(apiKey); + } else { + MessageDialog::showMessage(tr("You need to be logged in with Nexus"), parent); + } + } +} + void OrganizerCore::requestDownload(const QUrl &url, QNetworkReply *reply) { if (m_PluginContainer != nullptr) { diff --git a/src/organizercore.h b/src/organizercore.h index a4a57496..4dd11831 100644 --- a/src/organizercore.h +++ b/src/organizercore.h @@ -146,6 +146,7 @@ public: void updateModsInDirectoryStructure(QMap<unsigned int, ModInfo::Ptr> modInfos);
void doAfterLogin(const std::function<void()> &function) { m_PostLoginTasks.append(function); }
+ void loggedInAction(QWidget* parent, std::function<void ()> f);
static QString findJavaInstallation(const QString& jarFile={});
diff --git a/src/resources.qrc b/src/resources.qrc index 8645b27e..6fc33293 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -1,106 +1,108 @@ <RCC> - <qresource prefix="/MO/gui"> - <file>resources/help-browser.png</file> - <file alias="add">resources/list-add.png</file> - <file>resources/document-save.png</file> - <file>resources/edit-find-replace.png</file> - <file>resources/go-jump.png</file> - <file>resources/media-playback-start.png</file> - <file alias="remove">resources/process-stop.png</file> - <file>resources/system-search.png</file> - <file>resources/view-refresh.png</file> - <file alias="installer">resources/system-installer.png</file> - <file>resources/start-here.png</file> - <file>resources/list-remove.png</file> - <file>resources/document-properties.png</file> - <file>resources/go-up.png</file> - <file>resources/go-down.png</file> - <file>resources/switch-instance-icon.png</file> - <file alias="profiles">resources/contact-new.png</file> - <file alias="settings">resources/preferences-system.png</file> - <file alias="executable">resources/application-x-executable.png</file> - <file alias="information">resources/dialog-information.png</file> - <file alias="locked">resources/emblem-readonly.png</file> - <file alias="next">resources/go-next_16.png</file> - <file alias="previous">resources/go-previous_16.png</file> - <file alias="refresh">resources/view-refresh_16.png</file> - <file alias="update_available">resources/software-update-available.png</file> - <file alias="important">resources/emblem-important.png</file> - <file alias="check">resources/check.png</file> - <file alias="warning">resources/dialog-warning.png</file> - <file alias="emblem_backup">resources/symbol-backup.png</file> - <file alias="icon_tools">resources/applications-accessories.png</file> - <file alias="problem">resources/emblem-unreadable.png</file> - <file>resources/internet-web-browser.png</file> - <file alias="update">resources/system-software-update.png</file> - <file alias="help">resources/help-browser_32.png</file> - <file>resources/system-installer.png</file> - <file alias="icon_executable">resources/function.png</file> - <file alias="plugins">resources/plugins.png</file> - <file alias="edit_clear">resources/edit-clear.png</file> - <file alias="link">resources/dynamic-blue-right.png</file> - <file alias="icon_favorite">resources/icon-favorite.png</file> - <file alias="emblem_notendorsed">resources/emblem-favorite.png</file> - <file alias="emblem_conflict">resources/error.png</file> - <file alias="run">resources/show.png</file> - <file alias="splash">splash.png</file> - <file alias="emblem_conflict_mixed">resources/conflict-mixed.png</file> - <file alias="emblem_conflict_overwrite">resources/conflict-overwrite.png</file> - <file alias="emblem_conflict_overwritten">resources/conflict-overwritten.png</file> - <file alias="emblem_conflict_redundant">resources/conflict-redundant.png</file> - <file alias="archive_loose_conflict_mixed">resources/conflict-mixed-blue.png</file> - <file alias="archive_loose_conflict_overwrite">resources/conflict-overwrite-blue.png</file> - <file alias="archive_loose_conflict_overwritten">resources/red-archive-conflict-loser.png</file> - <file alias="emblem_notes">resources/accessories-text-editor.png</file> - <file alias="version_date">resources/x-office-calendar.png</file> - <file alias="warning_16">resources/dialog-warning_16.png</file> - <file alias="attachment">resources/mail-attachment.png</file> - <file alias="backup">resources/document-save_32.png</file> - <file alias="restore">resources/edit-undo.png</file> - <file alias="sort">resources/arrange-boxes.png</file> - <file alias="badge_1">resources/badge_1.png</file> - <file alias="badge_2">resources/badge_2.png</file> - <file alias="badge_3">resources/badge_3.png</file> - <file alias="badge_4">resources/badge_4.png</file> - <file alias="badge_5">resources/badge_5.png</file> - <file alias="badge_6">resources/badge_6.png</file> - <file alias="badge_7">resources/badge_7.png</file> - <file alias="badge_8">resources/badge_8.png</file> - <file alias="badge_9">resources/badge_9.png</file> - <file alias="badge_more">resources/badge_more.png</file> - <file alias="active">resources/status_active.png</file> - <file alias="awaiting">resources/status_awaiting.png</file> - <file alias="inactive">resources/status_inactive.png</file> - <file alias="app_icon">resources/mo_icon.png</file> - <file alias="package">resources/package.png</file> - <file alias="instance_switch">resources/switch-instance-icon.png</file> - <file alias="open_folder">resources/open-Folder-Icon.png</file> - <file alias="multiply_red">resources/multiply-red.png</file> - <file alias="archive_conflict_loser">resources/archive-conflict-loser.png</file> - <file alias="archive_conflict_mixed">resources/archive-conflict-mixed.png</file> - <file alias="archive_conflict_neutral">resources/archive-conflict-neutral.png</file> - <file alias="archive_conflict_winner">resources/archive-conflict-winner.png</file> - <file alias="alternate_game_alt">resources/game-warning.png</file> - <file alias="alternate_game">resources/game-warning-16.png</file> - <file alias="tracked">resources/tracked.png</file> - </qresource> - <qresource prefix="/MO/gui/content"> - <file alias="plugin">resources/contents/jigsaw-piece.png</file> - <file alias="skyproc">resources/contents/hand-of-god.png</file> - <file alias="texture">resources/contents/empty-chessboard.png</file> - <file alias="music">resources/contents/double-quaver.png</file> - <file alias="sound">resources/contents/lyre.png</file> - <file alias="interface">resources/contents/usable.png</file> - <file alias="skse">resources/contents/checkbox-tree.png</file> - <file alias="script">resources/contents/tinker.png</file> - <file alias="mesh">resources/contents/breastplate.png</file> - <file alias="string">resources/contents/conversation.png</file> - <file alias="bsa">resources/contents/locked-chest.png</file> - <file alias="menu">resources/contents/config.png</file> - <file alias="inifile">resources/contents/feather-and-scroll.png</file> - <file alias="modgroup">resources/contents/xedit.png</file> - </qresource> - <qresource prefix="/qt/etc"> - <file>qt.conf</file> - </qresource> + <qresource prefix="/MO/gui"> + <file alias="save">resources/save.svg</file> + <file alias="word-wrap">resources/word-wrap.svg</file> + <file>resources/help-browser.png</file> + <file alias="add">resources/list-add.png</file> + <file>resources/document-save.png</file> + <file>resources/edit-find-replace.png</file> + <file>resources/go-jump.png</file> + <file>resources/media-playback-start.png</file> + <file alias="remove">resources/process-stop.png</file> + <file>resources/system-search.png</file> + <file>resources/view-refresh.png</file> + <file alias="installer">resources/system-installer.png</file> + <file>resources/start-here.png</file> + <file>resources/list-remove.png</file> + <file>resources/document-properties.png</file> + <file>resources/go-up.png</file> + <file>resources/go-down.png</file> + <file>resources/switch-instance-icon.png</file> + <file alias="profiles">resources/contact-new.png</file> + <file alias="settings">resources/preferences-system.png</file> + <file alias="executable">resources/application-x-executable.png</file> + <file alias="information">resources/dialog-information.png</file> + <file alias="locked">resources/emblem-readonly.png</file> + <file alias="next">resources/go-next_16.png</file> + <file alias="previous">resources/go-previous_16.png</file> + <file alias="refresh">resources/view-refresh_16.png</file> + <file alias="update_available">resources/software-update-available.png</file> + <file alias="important">resources/emblem-important.png</file> + <file alias="check">resources/check.png</file> + <file alias="warning">resources/dialog-warning.png</file> + <file alias="emblem_backup">resources/symbol-backup.png</file> + <file alias="icon_tools">resources/applications-accessories.png</file> + <file alias="problem">resources/emblem-unreadable.png</file> + <file>resources/internet-web-browser.png</file> + <file alias="update">resources/system-software-update.png</file> + <file alias="help">resources/help-browser_32.png</file> + <file>resources/system-installer.png</file> + <file alias="icon_executable">resources/function.png</file> + <file alias="plugins">resources/plugins.png</file> + <file alias="edit_clear">resources/edit-clear.png</file> + <file alias="link">resources/dynamic-blue-right.png</file> + <file alias="icon_favorite">resources/icon-favorite.png</file> + <file alias="emblem_notendorsed">resources/emblem-favorite.png</file> + <file alias="emblem_conflict">resources/error.png</file> + <file alias="run">resources/show.png</file> + <file alias="splash">splash.png</file> + <file alias="emblem_conflict_mixed">resources/conflict-mixed.png</file> + <file alias="emblem_conflict_overwrite">resources/conflict-overwrite.png</file> + <file alias="emblem_conflict_overwritten">resources/conflict-overwritten.png</file> + <file alias="emblem_conflict_redundant">resources/conflict-redundant.png</file> + <file alias="archive_loose_conflict_mixed">resources/conflict-mixed-blue.png</file> + <file alias="archive_loose_conflict_overwrite">resources/conflict-overwrite-blue.png</file> + <file alias="archive_loose_conflict_overwritten">resources/red-archive-conflict-loser.png</file> + <file alias="emblem_notes">resources/accessories-text-editor.png</file> + <file alias="version_date">resources/x-office-calendar.png</file> + <file alias="warning_16">resources/dialog-warning_16.png</file> + <file alias="attachment">resources/mail-attachment.png</file> + <file alias="backup">resources/document-save_32.png</file> + <file alias="restore">resources/edit-undo.png</file> + <file alias="sort">resources/arrange-boxes.png</file> + <file alias="badge_1">resources/badge_1.png</file> + <file alias="badge_2">resources/badge_2.png</file> + <file alias="badge_3">resources/badge_3.png</file> + <file alias="badge_4">resources/badge_4.png</file> + <file alias="badge_5">resources/badge_5.png</file> + <file alias="badge_6">resources/badge_6.png</file> + <file alias="badge_7">resources/badge_7.png</file> + <file alias="badge_8">resources/badge_8.png</file> + <file alias="badge_9">resources/badge_9.png</file> + <file alias="badge_more">resources/badge_more.png</file> + <file alias="active">resources/status_active.png</file> + <file alias="awaiting">resources/status_awaiting.png</file> + <file alias="inactive">resources/status_inactive.png</file> + <file alias="app_icon">resources/mo_icon.png</file> + <file alias="package">resources/package.png</file> + <file alias="instance_switch">resources/switch-instance-icon.png</file> + <file alias="open_folder">resources/open-Folder-Icon.png</file> + <file alias="multiply_red">resources/multiply-red.png</file> + <file alias="archive_conflict_loser">resources/archive-conflict-loser.png</file> + <file alias="archive_conflict_mixed">resources/archive-conflict-mixed.png</file> + <file alias="archive_conflict_neutral">resources/archive-conflict-neutral.png</file> + <file alias="archive_conflict_winner">resources/archive-conflict-winner.png</file> + <file alias="alternate_game_alt">resources/game-warning.png</file> + <file alias="alternate_game">resources/game-warning-16.png</file> + <file alias="tracked">resources/tracked.png</file> + </qresource> + <qresource prefix="/MO/gui/content"> + <file alias="plugin">resources/contents/jigsaw-piece.png</file> + <file alias="skyproc">resources/contents/hand-of-god.png</file> + <file alias="texture">resources/contents/empty-chessboard.png</file> + <file alias="music">resources/contents/double-quaver.png</file> + <file alias="sound">resources/contents/lyre.png</file> + <file alias="interface">resources/contents/usable.png</file> + <file alias="skse">resources/contents/checkbox-tree.png</file> + <file alias="script">resources/contents/tinker.png</file> + <file alias="mesh">resources/contents/breastplate.png</file> + <file alias="string">resources/contents/conversation.png</file> + <file alias="bsa">resources/contents/locked-chest.png</file> + <file alias="menu">resources/contents/config.png</file> + <file alias="inifile">resources/contents/feather-and-scroll.png</file> + <file alias="modgroup">resources/contents/xedit.png</file> + </qresource> + <qresource prefix="/qt/etc"> + <file>qt.conf</file> + </qresource> </RCC> diff --git a/src/resources/go-next_16.png b/src/resources/go-next_16.png Binary files differindex 6ef8de76..58742d39 100644 --- a/src/resources/go-next_16.png +++ b/src/resources/go-next_16.png diff --git a/src/resources/go-previous_16.png b/src/resources/go-previous_16.png Binary files differindex 659cd90d..b4b22d04 100644 --- a/src/resources/go-previous_16.png +++ b/src/resources/go-previous_16.png diff --git a/src/resources/save.svg b/src/resources/save.svg new file mode 100644 index 00000000..3fb2a8df --- /dev/null +++ b/src/resources/save.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>
\ No newline at end of file diff --git a/src/resources/word-wrap.svg b/src/resources/word-wrap.svg new file mode 100644 index 00000000..63b1f1f0 --- /dev/null +++ b/src/resources/word-wrap.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M4 19h6v-2H4v2zM20 5H4v2h16V5zm-3 6H4v2h13.25c1.1 0 2 .9 2 2s-.9 2-2 2H15v-2l-3 3 3 3v-2h2c2.21 0 4-1.79 4-4s-1.79-4-4-4z"/><path d="M0 0h24v24H0z" fill="none"/></svg>
\ No newline at end of file diff --git a/src/stylesheets/vs15 Dark-Green.qss b/src/stylesheets/vs15 Dark-Green.qss index 9c57d96e..9cf26a31 100644 --- a/src/stylesheets/vs15 Dark-Green.qss +++ b/src/stylesheets/vs15 Dark-Green.qss @@ -49,7 +49,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/stylesheets/vs15 Dark-Orange.qss b/src/stylesheets/vs15 Dark-Orange.qss index bf7647b2..041f1b00 100644 --- a/src/stylesheets/vs15 Dark-Orange.qss +++ b/src/stylesheets/vs15 Dark-Orange.qss @@ -50,7 +50,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/stylesheets/vs15 Dark-Purple.qss b/src/stylesheets/vs15 Dark-Purple.qss index 1c9cb2b8..bc8bbcde 100644 --- a/src/stylesheets/vs15 Dark-Purple.qss +++ b/src/stylesheets/vs15 Dark-Purple.qss @@ -50,7 +50,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/stylesheets/vs15 Dark-Red.qss b/src/stylesheets/vs15 Dark-Red.qss index 73a90b15..0c9143cd 100644 --- a/src/stylesheets/vs15 Dark-Red.qss +++ b/src/stylesheets/vs15 Dark-Red.qss @@ -50,7 +50,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/stylesheets/vs15 Dark-Yellow.qss b/src/stylesheets/vs15 Dark-Yellow.qss index 1d43091d..2eb42534 100644 --- a/src/stylesheets/vs15 Dark-Yellow.qss +++ b/src/stylesheets/vs15 Dark-Yellow.qss @@ -50,7 +50,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/stylesheets/vs15 Dark.qss b/src/stylesheets/vs15 Dark.qss index dbeaaf28..d67cce35 100644 --- a/src/stylesheets/vs15 Dark.qss +++ b/src/stylesheets/vs15 Dark.qss @@ -49,7 +49,9 @@ QTreeWidget#categoriesTree::item, QListView::item, QTreeView#espList::item { - padding: 0.3em; } + padding-top: 0.3em; + padding-bottom: 0.3em; +} /* to enable border color */ QTreeView, diff --git a/src/texteditor.cpp b/src/texteditor.cpp new file mode 100644 index 00000000..130cd76f --- /dev/null +++ b/src/texteditor.cpp @@ -0,0 +1,533 @@ +#include "texteditor.h" +#include "utility.h" +#include <QSplitter> + +TextEditor::TextEditor(QWidget* parent) : + QPlainTextEdit(parent), + m_toolbar(nullptr), m_lineNumbers(nullptr), m_highlighter(nullptr), + m_dirty(false), m_loading(false) +{ + m_toolbar = new TextEditorToolbar(*this); + m_lineNumbers = new TextEditorLineNumbers(*this); + m_highlighter = new TextEditorHighlighter(document()); + + setDefaultStyle(); + wordWrap(true); + + emit modified(false); + + connect( + document(), &QTextDocument::modificationChanged, + [&](bool b){ onModified(b); }); + + connect( + this, &QPlainTextEdit::cursorPositionChanged, + [&]{ highlightCurrentLine(); }); +} + +void TextEditor::setDefaultStyle() +{ + const auto font = QFontDatabase::systemFont(QFontDatabase::FixedFont); + + setFont(font); + m_lineNumbers->setFont(font); + + QColor textColor(Qt::black); + QColor altTextColor(Qt::darkGray); + QColor backgroundColor(Qt::white); + + { + auto w = std::make_unique<QWidget>(); + + if (auto* s=style()) { + s->polish(w.get()); + } + + textColor = w->palette().color(QPalette::WindowText); + altTextColor = w->palette().color(QPalette::Disabled, QPalette::WindowText); + backgroundColor = w->palette().color(QPalette::Window); + } + + setTextColor(textColor); + m_lineNumbers->setTextColor(altTextColor); + + setBackgroundColor(backgroundColor); + m_lineNumbers->setBackgroundColor(backgroundColor); + + setHighlightBackgroundColor(backgroundColor); +} + +void TextEditor::clear() +{ + QScopedValueRollback loading(m_loading, true); + + m_filename.clear(); + m_encoding.clear(); + setPlainText(""); + dirty(false); + document()->setModified(false); + + emit loaded(""); +} + +bool TextEditor::load(const QString& filename) +{ + clear(); + + QScopedValueRollback loading(m_loading, true); + + m_filename = filename; + + const QString s = MOBase::readFileText(filename, &m_encoding); + + setPlainText(s); + document()->setModified(false); + + if (s.isEmpty()) { + // the modificationChanged even is not fired by the setModified() call + // above when the text being set is empty + onModified(false); + } + + emit loaded(m_filename); + + return true; +} + +bool TextEditor::save() +{ + if (m_filename.isEmpty() || m_encoding.isEmpty()) { + return false; + } + + QFile file(m_filename); + file.open(QIODevice::WriteOnly); + file.resize(0); + + QTextCodec* codec = QTextCodec::codecForName(m_encoding.toUtf8()); + QString data = toPlainText().replace("\n", "\r\n"); + + file.write(codec->fromUnicode(data)); + document()->setModified(false); + + return true; +} + +const QString& TextEditor::filename() const +{ + return m_filename; +} + +void TextEditor::wordWrap(bool b) +{ + if (b) { + setLineWrapMode(QPlainTextEdit::WidgetWidth); + } else { + setLineWrapMode(QPlainTextEdit::NoWrap); + } + + emit wordWrapChanged(b); +} + +void TextEditor::toggleWordWrap() +{ + wordWrap(!wordWrap()); +} + +bool TextEditor::wordWrap() const +{ + return (lineWrapMode() == QPlainTextEdit::WidgetWidth); +} + +void TextEditor::dirty(bool b) +{ + m_dirty = b; +} + +bool TextEditor::dirty() const +{ + return m_dirty; +} + +QColor TextEditor::backgroundColor() const +{ + return m_highlighter->backgroundColor(); +} + +void TextEditor::setBackgroundColor(const QColor& c) +{ + if (m_highlighter->backgroundColor() == c) { + return; + } + + m_highlighter->setBackgroundColor(c); + + setStyleSheet(QString("QPlainTextEdit{ background-color: rgba(%1, %2, %3, %4); }") + .arg(c.redF() * 255) + .arg(c.greenF() * 255) + .arg(c.blueF() * 255) + .arg(c.alphaF())); +} + +QColor TextEditor::textColor() const +{ + return m_highlighter->textColor(); +} + +void TextEditor::setTextColor(const QColor& c) +{ + m_highlighter->setTextColor(c); +} + +QColor TextEditor::highlightBackgroundColor() const +{ + return m_highlightBackground; +} + +void TextEditor::setHighlightBackgroundColor(const QColor& c) +{ + m_highlightBackground = c; + update(); +} + +void TextEditor::explore() +{ + if (m_filename.isEmpty()) { + return; + } + + MOBase::shell::ExploreFile(m_filename); +} + +void TextEditor::onModified(bool b) +{ + if (m_loading) { + return; + } + + dirty(b); + emit modified(b); +} + +void TextEditor::setupToolbar() +{ + auto* widget = wrapEditWidget(); + if (!widget) { + return; + } + + auto* layout = new QVBoxLayout(widget); + + // adding toolbar and edit + layout->addWidget(m_toolbar); + layout->addWidget(this); + + // make the edit stretch + layout->setStretch(0, 0); + layout->setStretch(1, 1); + + // visuals + layout->setContentsMargins(0, 0, 0, 0); + widget->show(); +} + +QWidget* TextEditor::wrapEditWidget() +{ + auto widget = std::make_unique<QWidget>(); + + // wrapping the QPlainTextEdit into a new widget so the toolbar can be + // displayed above it + + if (auto* parentLayout=parentWidget()->layout()) { + // the edit's parent has a regular layout, replace the edit by the new + // widget and delete the QLayoutItem that's returned as it's not needed + delete parentLayout->replaceWidget(this, widget.get()); + + } else if (auto* splitter=qobject_cast<QSplitter*>(parentWidget())) { + // the edit's parent is a QSplitter, which doesn't have a layout; replace + // the edit by using its index in the splitter + auto index = splitter->indexOf(this); + + if (index == -1) { + qCritical( + "TextEditor: cannot wrap edit widget to display a toolbar, " + "parent is a splitter, but widget isn't in it"); + + return nullptr; + } + + splitter->replaceWidget(index, widget.get()); + + } else { + // unknown parent + qCritical( + "TextEditor: cannot wrap edit widget to display a toolbar, " + "no parent or parent has no layout"); + + return nullptr; + } + + return widget.release(); +} + +void TextEditor::resizeEvent(QResizeEvent* e) +{ + QPlainTextEdit::resizeEvent(e); + + QRect cr = contentsRect(); + m_lineNumbers->setGeometry(QRect(cr.left(), cr.top(), m_lineNumbers->areaWidth(), cr.height())); +} + +void TextEditor::paintLineNumbers(QPaintEvent* e, const QColor& textColor) +{ + QStyleOption opt; + opt.init(m_lineNumbers); + + QPainter painter(m_lineNumbers); + + QTextBlock block = firstVisibleBlock(); + int blockNumber = block.blockNumber(); + int top = (int) blockBoundingGeometry(block).translated(contentOffset()).top(); + int bottom = top + (int) blockBoundingRect(block).height(); + + while (block.isValid() && top <= e->rect().bottom()) { + if (block.isVisible() && bottom >= e->rect().top()) { + QString number = QString::number(blockNumber + 1); + painter.setPen(textColor); + + painter.drawText( + 0, top, m_lineNumbers->width() - 3, fontMetrics().height(), + Qt::AlignRight, number); + } + + block = block.next(); + top = bottom; + bottom = top + (int) blockBoundingRect(block).height(); + ++blockNumber; + } +} + +void TextEditor::highlightCurrentLine() +{ + QList<QTextEdit::ExtraSelection> extraSelections; + + if (!isReadOnly()) { + QTextEdit::ExtraSelection selection; + + QColor lineColor = QColor(Qt::yellow).lighter(160); + + selection.format.setBackground(m_highlightBackground); + selection.format.setProperty(QTextFormat::FullWidthSelection, true); + selection.cursor = textCursor(); + selection.cursor.clearSelection(); + extraSelections.append(selection); + } + + setExtraSelections(extraSelections); +} + + +TextEditorHighlighter::TextEditorHighlighter(QTextDocument* doc) : + QSyntaxHighlighter(doc), + m_background(QColor("transparent")), + m_text(QColor("black")) +{ +} + +QColor TextEditorHighlighter::backgroundColor() const +{ + return m_background; +} + +void TextEditorHighlighter::setBackgroundColor(const QColor& c) +{ + m_background = c; + changed(); +} + +QColor TextEditorHighlighter::textColor() const +{ + return m_text; +} + +void TextEditorHighlighter::setTextColor(const QColor& c) +{ + m_text = c; + changed(); +} + +void TextEditorHighlighter::highlightBlock(const QString& s) +{ + QTextCharFormat f; + f.setBackground(m_background); + f.setForeground(m_text); + + setFormat(0, s.size(), f); +} + +void TextEditorHighlighter::changed() +{ + rehighlight(); +} + + +TextEditorLineNumbers::TextEditorLineNumbers(TextEditor& editor) + : QFrame(&editor), m_editor(editor) +{ + setFont(editor.font()); + + connect(&m_editor, &QPlainTextEdit::blockCountChanged, [&]{ updateAreaWidth(); }); + connect(&m_editor, &QPlainTextEdit::updateRequest, [&](auto&& rect, int dy){ updateArea(rect, dy); }); + + updateAreaWidth(); +} + +QSize TextEditorLineNumbers::sizeHint() const +{ + return QSize(areaWidth(), 0); +} + +int TextEditorLineNumbers::areaWidth() const +{ + int digits = 1; + int max = std::max(1, m_editor.blockCount()); + + while (max >= 10) { + max /= 10; + ++digits; + } + + digits = std::max(3, digits); + + int space = 3 + fontMetrics().horizontalAdvance(QLatin1Char('9')) * digits + 3; + + return space; +} + +QColor TextEditorLineNumbers::textColor() const +{ + return m_text; +} + +void TextEditorLineNumbers::setTextColor(const QColor& c) +{ + m_text = c; + m_editor.update(); +} + +QColor TextEditorLineNumbers::backgroundColor() const +{ + return m_background; +} + +void TextEditorLineNumbers::setBackgroundColor(const QColor& c) +{ + m_background = c; + m_editor.update(); +} + +void TextEditorLineNumbers::paintEvent(QPaintEvent* e) +{ + QPainter painter(this); + painter.fillRect(e->rect(), m_background); + + QFrame::paintEvent(e); + m_editor.paintLineNumbers(e, m_text); +} + +void TextEditorLineNumbers::updateAreaWidth() +{ + m_editor.setViewportMargins(areaWidth(), 0, 0, 0); +} + +void TextEditorLineNumbers::updateArea(const QRect &rect, int dy) +{ + if (dy) { + scroll(0, dy); + } else { + update(0, rect.y(), width(), rect.height()); + } + + if (rect.contains(m_editor.viewport()->rect())) { + updateAreaWidth(); + } +} + + +TextEditorToolbar::TextEditorToolbar(TextEditor& editor) : + m_editor(editor), m_save(nullptr), m_wordWrap(nullptr), m_explore(nullptr), + m_path(nullptr) +{ + m_save = new QAction( + QIcon(":/MO/gui/save"), QObject::tr("&Save"), &editor); + + m_save->setShortcutContext(Qt::WidgetWithChildrenShortcut); + m_save->setShortcut(Qt::CTRL + Qt::Key_S); + m_editor.addAction(m_save); + + m_wordWrap = new QAction( + QIcon(":/MO/gui/word-wrap"), QObject::tr("&Word wrap"), &editor); + + m_wordWrap->setCheckable(true); + + m_explore = new QAction( + QObject::tr("&Open in Explorer"), &editor); + + m_path = new QLineEdit; + m_path->setReadOnly(true); + + QObject::connect(m_save, &QAction::triggered, [&]{ m_editor.save(); }); + QObject::connect(m_wordWrap, &QAction::triggered, [&]{ m_editor.toggleWordWrap(); }); + QObject::connect(m_explore, &QAction::triggered, [&]{ m_editor.explore(); }); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setAlignment(Qt::AlignLeft); + + auto* b = new QToolButton; + b->setDefaultAction(m_save); + layout->addWidget(b); + + b = new QToolButton; + b->setDefaultAction(m_wordWrap); + layout->addWidget(b); + + b = new QToolButton; + b->setDefaultAction(m_explore); + layout->addWidget(b); + + layout->addWidget(m_path); + + QObject::connect(&m_editor, &TextEditor::modified, [&](bool b){ onTextModified(b); }); + QObject::connect(&m_editor, &TextEditor::wordWrapChanged, [&](bool b){ onWordWrap(b); }); + QObject::connect(&m_editor, &TextEditor::loaded, [&](QString f){ onLoaded(f); }); +} + +void TextEditorToolbar::onTextModified(bool b) +{ + m_save->setEnabled(b); +} + +void TextEditorToolbar::onWordWrap(bool b) +{ + m_wordWrap->setChecked(b); +} + +void TextEditorToolbar::onLoaded(const QString& path) +{ + const auto hasDoc = !path.isEmpty(); + + m_explore->setEnabled(hasDoc); + m_wordWrap->setEnabled(hasDoc); + m_path->setEnabled(hasDoc); + m_path->setText(path); +} + +void HTMLEditor::focusOutEvent(QFocusEvent* e) +{ + if (document() && document()->isModified()) { + emit editingFinished(); + } + + QTextEdit::focusInEvent(e); +} diff --git a/src/texteditor.h b/src/texteditor.h new file mode 100644 index 00000000..dc53f1c4 --- /dev/null +++ b/src/texteditor.h @@ -0,0 +1,165 @@ +#ifndef MO_TEXTEDITOR_H +#define MO_TEXTEDITOR_H + +#include <QPlainTextEdit> + +class TextEditor; + +class TextEditorToolbar : public QFrame +{ + Q_OBJECT; + +public: + TextEditorToolbar(TextEditor& editor); + +private: + TextEditor& m_editor; + QAction* m_save; + QAction* m_wordWrap; + QAction* m_explore; + QLineEdit* m_path; + + void onTextModified(bool b); + void onWordWrap(bool b); + void onLoaded(const QString& s); +}; + + +// mostly from https://doc.qt.io/qt-5/qtwidgets-widgets-codeeditor-example.html +// +class TextEditorLineNumbers : public QFrame +{ + Q_OBJECT; + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor); + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor); + +public: + TextEditorLineNumbers(TextEditor& editor); + + QSize sizeHint() const override; + int areaWidth() const; + + QColor textColor() const; + void setTextColor(const QColor& c); + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + TextEditor& m_editor; + QColor m_background, m_text; + + void updateAreaWidth(); + void updateArea(const QRect &rect, int dy); +}; + + +class TextEditorHighlighter : public QSyntaxHighlighter +{ + Q_OBJECT; + +public: + TextEditorHighlighter(QTextDocument* doc); + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + + QColor textColor() const; + void setTextColor(const QColor& c); + +protected: + void highlightBlock(const QString& text) override; + +private: + QColor m_background, m_text; + + void changed(); +}; + + +class TextEditor : public QPlainTextEdit +{ + Q_OBJECT; + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor); + Q_PROPERTY(QColor backgroundColor READ backgroundColor WRITE setBackgroundColor); + Q_PROPERTY(QColor highlightBackgroundColor READ highlightBackgroundColor WRITE setHighlightBackgroundColor); + + friend class TextEditorLineNumbers; + +public: + TextEditor(QWidget* parent=nullptr); + + void setupToolbar(); + + void clear(); + bool load(const QString& filename); + bool save(); + + const QString& filename() const; + + void wordWrap(bool b); + void toggleWordWrap(); + bool wordWrap() const; + + bool dirty() const; + + QColor backgroundColor() const; + void setBackgroundColor(const QColor& c); + + QColor textColor() const; + void setTextColor(const QColor& c); + + QColor highlightBackgroundColor() const; + void setHighlightBackgroundColor(const QColor& c); + + void explore(); + +signals: + void loaded(QString filename); + void modified(bool b); + void wordWrapChanged(bool b); + +protected: + void resizeEvent(QResizeEvent* e) override; + +private: + TextEditorToolbar* m_toolbar; + TextEditorLineNumbers* m_lineNumbers; + TextEditorHighlighter* m_highlighter; + QColor m_highlightBackground; + QString m_filename; + QString m_encoding; + bool m_dirty; + bool m_loading; + + void setDefaultStyle(); + void onModified(bool b); + void dirty(bool b); + + QWidget* wrapEditWidget(); + + void highlightCurrentLine(); + void paintLineNumbers(QPaintEvent* e, const QColor& textColor); +}; + + +class HTMLEditor : public QTextEdit +{ + Q_OBJECT; + +public: + using QTextEdit::QTextEdit; + +signals: + void editingFinished(); + +protected: + void focusOutEvent(QFocusEvent* e); + +private: +}; + +#endif // MO_TEXTEDITOR_H |
